nio-cat 0.5.1 → 0.8.0
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/README.md +39 -6
- package/bin/nio.js +158 -9
- package/package.json +3 -2
- package/src/agents/claude.js +8 -0
- package/src/audit.js +55 -0
- package/src/auth.js +175 -0
- package/src/claude-hook.js +6 -0
- package/src/control.js +238 -0
- package/src/directory.js +56 -0
- package/src/hub.js +33 -12
- package/src/jobs.js +287 -0
- package/src/mcp.js +142 -0
- package/src/memory.js +131 -0
- package/src/proc.js +1 -1
- package/src/push.js +52 -18
- package/src/server.js +97 -33
- package/src/service.js +123 -0
- package/src/telegram.js +311 -0
- package/src/tunnel.js +150 -34
- package/src/voice.js +164 -0
- package/web/app/app.js +693 -86
- package/web/app/style.css +68 -7
- package/web/board.css +3 -2
- package/web/board.js +15 -14
- package/web/index.html +35 -17
- package/web/privadesa.html +148 -0
- package/web/sw.js +13 -9
package/web/app/app.js
CHANGED
|
@@ -12,8 +12,19 @@ const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform
|
|
|
12
12
|
const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
|
|
13
13
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
14
14
|
const bot = (mood = 'idle', cls = '') => (window.NioBot ? NioBot.html(mood, cls) : '');
|
|
15
|
+
const CHIP_ICON = {
|
|
16
|
+
mode: '<path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z"/>',
|
|
17
|
+
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
18
|
+
model: '<rect x="6" y="6" width="12" height="12" rx="2.5"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/>',
|
|
19
|
+
cost: '<circle cx="12" cy="12" r="9"/><path d="M14.6 9.3c-.6-.8-1.500-1.300-2.600-1.300-1.400 0-2.500.8-2.500 2 0 2.700 5 1.300 5 4 0 1.200-1.100 2-2.500 2-1.100 0-2-.5-2.600-1.300M12 6.500V8M12 16v1.500"/>'
|
|
20
|
+
};
|
|
15
21
|
const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
16
22
|
const ICON = {
|
|
23
|
+
activity: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
|
|
24
|
+
jobs: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h5M8 17h3"/>',
|
|
25
|
+
screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M9 20h6M12 16v4"/><circle cx="12" cy="10" r="2"/>',
|
|
26
|
+
panic: '<circle cx="12" cy="12" r="9"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7"/>',
|
|
27
|
+
memory: '<path d="M6 4h9l3 3v13H6z"/><path d="M9 11h6M9 15h6M14 4v4h4"/>',
|
|
17
28
|
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
18
29
|
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
19
30
|
phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
|
|
@@ -46,7 +57,7 @@ const MODES = {
|
|
|
46
57
|
};
|
|
47
58
|
|
|
48
59
|
/* ---------------------------------------------------------------- estat */
|
|
49
|
-
const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set() };
|
|
60
|
+
const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set(), synced: false };
|
|
50
61
|
const LS = {
|
|
51
62
|
get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
|
52
63
|
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
|
|
@@ -137,9 +148,81 @@ async function api(path, opts = {}) {
|
|
|
137
148
|
return data;
|
|
138
149
|
}
|
|
139
150
|
|
|
140
|
-
let streamCtrl = null, retry = 0;
|
|
151
|
+
let streamCtrl = null, retry = 0, gotHello = false;
|
|
152
|
+
async function pollLoop(ctrl) {
|
|
153
|
+
let first = true;
|
|
154
|
+
while (!ctrl.signal.aborted) {
|
|
155
|
+
try {
|
|
156
|
+
const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
|
|
157
|
+
if (ctrl.signal.aborted) return;
|
|
158
|
+
state.connected = true; state.lastError = null;
|
|
159
|
+
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(); }
|
|
160
|
+
else {
|
|
161
|
+
const r = state.route, seen = new Set();
|
|
162
|
+
for (const x of list) {
|
|
163
|
+
seen.add(x.id); const old = state.sessions.get(x.id);
|
|
164
|
+
if (!old || old.updated !== x.updated || old.state !== x.state || old.pending !== x.pending || old.title !== x.title) handle({ type: 'session', session: x });
|
|
165
|
+
}
|
|
166
|
+
for (const id of [...state.sessions.keys()]) if (!seen.has(id)) handle({ type: 'removed', session: id });
|
|
167
|
+
for (const a of aps) if (!state.approvals.has(a.id)) handle({ type: 'approval', approval: a });
|
|
168
|
+
for (const id of [...state.approvals.keys()]) if (!aps.some((a) => a.id === id)) { state.approvals.delete(id); renderChrome(); if (r.name === 'approvals') render(); }
|
|
169
|
+
const evs = r.name === 'session' && state.events.get(r.id);
|
|
170
|
+
if (evs && state.sessions.has(r.id)) {
|
|
171
|
+
const since = Math.max(0, ...evs.keys());
|
|
172
|
+
const d = await api(`/api/sessions/${r.id}?since=${since}`);
|
|
173
|
+
for (const e of d.events) if (!evs.has(e.seq)) handle({ type: 'event', session: r.id, event: e });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (ctrl.signal.aborted) return;
|
|
178
|
+
state.connected = false; state.lastError = 'No arribo a l’ordinador'; render();
|
|
179
|
+
return setTimeout(() => machine() && !ctrl.signal.aborted && connect(), 3000);
|
|
180
|
+
}
|
|
181
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/* ---------- Adreça recordada a nio.cat
|
|
185
|
+
Cada ordinador publica la seva adreça actual a nio.cat, signada amb una clau Ed25519 pròpia.
|
|
186
|
+
Si el túnel canvia d'adreça i no hi arribem, la busquem allà i només la fem servir si la
|
|
187
|
+
signatura és de la clau que vam guardar en aparellar-nos (nio.cat no pot enganyar-nos). */
|
|
188
|
+
const DIRECTORY = 'https://www.nio.cat';
|
|
189
|
+
function rememberIdentity(url, ident) {
|
|
190
|
+
if (!ident?.pub || !ident?.loc) return;
|
|
191
|
+
const ms = store.machines, m = ms.find((x) => x.url === url);
|
|
192
|
+
if (!m || m.pub === ident.pub) return;
|
|
193
|
+
if (m.pub) return; // una altra clau a la mateixa adreça: no la canviem mai sola
|
|
194
|
+
m.pub = ident.pub; m.loc = ident.loc; store.machines = ms;
|
|
195
|
+
}
|
|
196
|
+
const b64uBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4)), (c) => c.charCodeAt(0));
|
|
197
|
+
async function signedBy(rec, m) {
|
|
198
|
+
if (rec.pub !== m.pub) return false;
|
|
199
|
+
try {
|
|
200
|
+
const key = await crypto.subtle.importKey('raw', b64uBytes(m.pub), { name: 'Ed25519' }, false, ['verify']);
|
|
201
|
+
return await crypto.subtle.verify({ name: 'Ed25519' }, key, b64uBytes(rec.sig), new TextEncoder().encode(`nio-node-v1\n${m.loc}\n${rec.url}\n${rec.t}`));
|
|
202
|
+
} catch {
|
|
203
|
+
// Navegador sense Ed25519 (iOS < 17, Chrome < 113): ens refiem de la comprovació que fa nio.cat.
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const relocating = new Map();
|
|
208
|
+
async function relocate(i) {
|
|
209
|
+
const ms = store.machines, m = ms[i];
|
|
210
|
+
if (!m?.loc || Date.now() - (relocating.get(m.loc) || 0) < 15000) return false;
|
|
211
|
+
relocating.set(m.loc, Date.now());
|
|
212
|
+
try {
|
|
213
|
+
const res = await fetch(`${DIRECTORY}/api/node?id=${encodeURIComponent(m.loc)}`, { cache: 'no-store', signal: AbortSignal.timeout(6000) });
|
|
214
|
+
if (!res.ok) return false;
|
|
215
|
+
const rec = await res.json();
|
|
216
|
+
if (!rec.url || rec.url === m.url || (m.locT && rec.t <= m.locT) || !/^https:\/\//.test(rec.url) || !(await signedBy(rec, m))) return false;
|
|
217
|
+
const fresh = store.machines, x = fresh.find((y) => y.loc === m.loc); if (!x) return false;
|
|
218
|
+
x.url = rec.url; x.locT = rec.t; store.machines = fresh;
|
|
219
|
+
if (i === store.active) toast('L’ordinador ha canviat d’adreça. Ja hi torno a ser.');
|
|
220
|
+
return true;
|
|
221
|
+
} catch { return false; }
|
|
222
|
+
}
|
|
223
|
+
|
|
141
224
|
async function connect() {
|
|
142
|
-
streamCtrl?.abort();
|
|
225
|
+
streamCtrl?.abort(); state.synced = false;
|
|
143
226
|
const m = machine();
|
|
144
227
|
if (!m) { state.connected = false; return render(); }
|
|
145
228
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
@@ -149,10 +232,14 @@ async function connect() {
|
|
|
149
232
|
if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
|
|
150
233
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
151
234
|
state.connected = true; state.lastError = null; retry = 0;
|
|
152
|
-
|
|
235
|
+
rememberIdentity(m.url, state.info.identity);
|
|
236
|
+
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.());
|
|
153
237
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
238
|
+
// Alguns túnels (Cloudflare des d'alguns servidors) no deixen passar el flux en directe: sense «hello» en 5 s, es passa a consultar cada 2 s.
|
|
239
|
+
gotHello = false; let poll = false;
|
|
240
|
+
const hw = setTimeout(() => { if (!gotHello) { poll = true; reader.cancel().catch(() => {}); } }, 5000);
|
|
154
241
|
for (;;) {
|
|
155
|
-
const { value, done } = await reader.read(); if (done) break;
|
|
242
|
+
const { value, done } = await reader.read().catch(() => ({ done: true })); if (done) break;
|
|
156
243
|
buf += dec.decode(value, { stream: true }); let i;
|
|
157
244
|
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
158
245
|
const block = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
@@ -160,6 +247,8 @@ async function connect() {
|
|
|
160
247
|
if (data) handle(JSON.parse(data));
|
|
161
248
|
}
|
|
162
249
|
}
|
|
250
|
+
clearTimeout(hw);
|
|
251
|
+
if (poll) return pollLoop(ctrl);
|
|
163
252
|
throw new Error('S’ha tallat la connexió');
|
|
164
253
|
} catch (e) {
|
|
165
254
|
if (ctrl.signal.aborted) return;
|
|
@@ -167,6 +256,8 @@ async function connect() {
|
|
|
167
256
|
state.lastError = e.message === 'Failed to fetch' || e.name === 'TypeError' ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
|
|
168
257
|
render();
|
|
169
258
|
if (e.fatal) return;
|
|
259
|
+
// Potser el túnel ha canviat d'adreça: la busquem a nio.cat (signada per l'ordinador).
|
|
260
|
+
if (state.lastError === 'No arribo a l’ordinador' && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
|
|
170
261
|
if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
|
|
171
262
|
setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
|
|
172
263
|
}
|
|
@@ -213,6 +304,7 @@ function handle(msg) {
|
|
|
213
304
|
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
214
305
|
break;
|
|
215
306
|
case 'hello':
|
|
307
|
+
gotHello = true; state.synced = true;
|
|
216
308
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
217
309
|
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
218
310
|
state.loaded.clear(); state.events.clear();
|
|
@@ -230,10 +322,12 @@ function handle(msg) {
|
|
|
230
322
|
break;
|
|
231
323
|
case 'event': {
|
|
232
324
|
const evs = state.events.get(msg.session);
|
|
233
|
-
if (evs) { evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) putEvent(msg.event); }
|
|
325
|
+
if (evs) { const was = evs.get(msg.event.seq); evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) { putEvent(msg.event); if (msg.event.kind === 'text' && !msg.event.open && (!was || was.open)) autoSpeak(msg.event); } }
|
|
234
326
|
break;
|
|
235
327
|
}
|
|
236
328
|
case 'ollama': onOllama(msg); break;
|
|
329
|
+
case 'job': onJob(msg); break;
|
|
330
|
+
case 'screen': onScreen(msg); break;
|
|
237
331
|
case 'approval':
|
|
238
332
|
if (msg.approval.state === 'pending') { state.approvals.set(msg.approval.id, msg.approval); if (!document.hidden && navigator.vibrate) navigator.vibrate(30); }
|
|
239
333
|
else if (state.approvals.has(msg.approval.id)) { markDecided(msg.approval.id, msg.approval.state); break; }
|
|
@@ -281,9 +375,33 @@ function addMachine({ url, token, name, kind, via }) {
|
|
|
281
375
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
282
376
|
connect();
|
|
283
377
|
}
|
|
378
|
+
// El codi o l'enllaç porta un secret d'un sol ús: el canviem per una clau pròpia d'aquest dispositiu,
|
|
379
|
+
// que es pot revocar des d'Ordinadors. Si és la clau principal (el panell que obre nio) o un nio antic
|
|
380
|
+
// que encara no fa aparellament, el valor ja és una clau i el fem servir tal qual.
|
|
381
|
+
function deviceName() {
|
|
382
|
+
const ua = navigator.userAgent;
|
|
383
|
+
const dev = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) || (isIOS && !/iPhone/.test(ua)) ? 'iPad' : /Android/.test(ua) ? (ua.match(/Android[^;]*;\s*([^;)]+?)(?:\sBuild|\))/)?.[1] || 'Android') : /Mac/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : 'Dispositiu';
|
|
384
|
+
const br = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : '';
|
|
385
|
+
return `${dev}${br ? ` · ${br}` : ''}${standalone ? ' (app)' : ''}`.slice(0, 60);
|
|
386
|
+
}
|
|
387
|
+
async function claimPair(pair) {
|
|
388
|
+
let res = null;
|
|
389
|
+
try { res = await fetch(`${pair.url}/api/pair`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code: pair.token, name: deviceName() }), cache: 'no-store' }); }
|
|
390
|
+
catch { return pair; } // sense connexió ara: ho resoldrà la connexió
|
|
391
|
+
if (res.ok) { const d = await res.json(); return { ...pair, token: d.token, name: pair.name || d.host, identity: d.identity }; }
|
|
392
|
+
const why = (await res.json().catch(() => ({}))).error;
|
|
393
|
+
const ok = await fetch(`${pair.url}/api/info`, { headers: { Authorization: `Bearer ${pair.token}` }, cache: 'no-store' }).then((r) => r.ok).catch(() => false);
|
|
394
|
+
if (ok) return pair;
|
|
395
|
+
throw new Error(why || 'Aquest codi no és vàlid o ha caducat. Demana’n un de nou amb «nio pair».');
|
|
396
|
+
}
|
|
397
|
+
async function pairAndAdd(pair, { msg = 'Ordinador connectat', after } = {}) {
|
|
398
|
+
try { const p = await claimPair(pair); addMachine(p); rememberIdentity(p.url, p.identity); toast(msg); after?.(); }
|
|
399
|
+
catch (e) { toast(e.message, 6000); }
|
|
400
|
+
render();
|
|
401
|
+
}
|
|
284
402
|
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
285
403
|
function switchMachine(i) {
|
|
286
|
-
closeSheet(); store.active = i; streamCtrl?.abort();
|
|
404
|
+
closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; nodeVoice = null;
|
|
287
405
|
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
|
|
288
406
|
connect();
|
|
289
407
|
if (state.route.name === 'session') go('#/sessions'); else render();
|
|
@@ -300,9 +418,9 @@ function checkPairHash() {
|
|
|
300
418
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
301
419
|
state.route = { name: 'sessions' };
|
|
302
420
|
const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
|
|
303
|
-
|
|
421
|
+
render();
|
|
304
422
|
// Quan nio obre el panell, el QR per al mòbil surt tot seguit, sense cap pestanya nova.
|
|
305
|
-
if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500);
|
|
423
|
+
pairAndAdd(pair, { after: () => { if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500); } });
|
|
306
424
|
return true;
|
|
307
425
|
}
|
|
308
426
|
|
|
@@ -310,6 +428,10 @@ function checkPairHash() {
|
|
|
310
428
|
const VIEWS = {
|
|
311
429
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
312
430
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
431
|
+
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
432
|
+
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Feina' },
|
|
433
|
+
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
|
|
434
|
+
pantalla: { label: 'Pantalla', icon: ICON.screen, group: 'Ordinadors' },
|
|
313
435
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
314
436
|
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
315
437
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
@@ -317,8 +439,8 @@ const VIEWS = {
|
|
|
317
439
|
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
318
440
|
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
319
441
|
};
|
|
320
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
321
|
-
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
|
|
442
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', pantalla: '#/pantalla', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
443
|
+
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', 'pantalla', 'models', 'session']);
|
|
322
444
|
function parseRoute() {
|
|
323
445
|
const h = location.hash.replace(/^#\/?/, '');
|
|
324
446
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
@@ -339,6 +461,7 @@ function route() {
|
|
|
339
461
|
|
|
340
462
|
/* ---------------------------------------------------------------- pintat general */
|
|
341
463
|
function renderChrome() {
|
|
464
|
+
renderPanic();
|
|
342
465
|
const r = state.route, n = state.approvals.size, active = r.name === 'session' ? 'sessions' : r.name;
|
|
343
466
|
let prev = '';
|
|
344
467
|
$('#nav').innerHTML = Object.entries(VIEWS).map(([k, v], i) => {
|
|
@@ -363,6 +486,7 @@ function renderChrome() {
|
|
|
363
486
|
}
|
|
364
487
|
function render() {
|
|
365
488
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
489
|
+
if (r.name !== 'sessions') manage = null;
|
|
366
490
|
renderChrome();
|
|
367
491
|
document.body.classList.toggle('board-mode', board);
|
|
368
492
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
|
|
@@ -375,22 +499,44 @@ function render() {
|
|
|
375
499
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
376
500
|
else {
|
|
377
501
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
378
|
-
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
|
|
502
|
+
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'pantalla' ? viewScreen() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
|
|
379
503
|
if (r.name === 'mobil') bindLocal();
|
|
380
504
|
if (r.name === 'models' && machine()) loadOllama();
|
|
505
|
+
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
506
|
+
if (r.name === 'memoria' && machine()) loadMemory();
|
|
507
|
+
if (r.name === 'pantalla' && machine()) loadScreen();
|
|
508
|
+
if (r.name === 'feines' && machine()) loadJobs();
|
|
509
|
+
hydrateShots();
|
|
381
510
|
}
|
|
382
511
|
if (changed && !board) { view.classList.remove('view-enter'); void view.offsetWidth; view.classList.add('view-enter'); }
|
|
383
512
|
window.NioBot?.refresh();
|
|
384
513
|
tourUpdate();
|
|
514
|
+
if (!board) setTimeout(focusMain, 60);
|
|
385
515
|
}
|
|
386
516
|
|
|
387
517
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
388
518
|
const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
|
|
389
519
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
390
520
|
const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
521
|
+
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
522
|
+
const reach = new Map();
|
|
523
|
+
let reachAt = 0;
|
|
524
|
+
async function probeAll(force) {
|
|
525
|
+
if (!force && Date.now() - reachAt < 20000) return;
|
|
526
|
+
reachAt = Date.now();
|
|
527
|
+
await Promise.all(store.machines.map(async (m, i) => {
|
|
528
|
+
if (i === store.active) return;
|
|
529
|
+
let ok = await fetch(`${m.url}/api/info`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) }).then((r) => r.ok).catch(() => false);
|
|
530
|
+
if (!ok && (await relocate(i))) { reach.set(store.machines[i].url, true); return render(); }
|
|
531
|
+
if (reach.get(m.url) !== ok) { reach.set(m.url, ok); if (state.route.name === 'ordinadors') { const y = scrollY; render(); scrollTo(0, y); } }
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
setInterval(() => { if (!document.hidden && state.route.name === 'ordinadors') probeAll(); }, 30000);
|
|
391
535
|
function computerCard(m, i) {
|
|
392
536
|
const on = i === store.active;
|
|
393
|
-
const
|
|
537
|
+
const pr = reach.get(m.url);
|
|
538
|
+
const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`)
|
|
539
|
+
: pr == null ? '' : pr ? '<span class="chip ok"><i class="dot on"></i>En línia</span>' : '<span class="chip"><i class="dot"></i>Fora de línia</span>';
|
|
394
540
|
return `<div class="card computer ${on ? 'active' : ''}">
|
|
395
541
|
<div class="row" style="flex-wrap:nowrap;gap:12px;align-items:flex-start"><span class="c-ico">${svg(machineIcon(m))}</span><div class="grow"><div class="row between"><b>${esc(m.name)}</b>${st}</div><div class="muted small">${esc(machineSub(m))}</div></div></div>
|
|
396
542
|
<div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-rename="${i}" type="button">Canvia el nom</button><button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
|
|
@@ -408,10 +554,28 @@ function helpPanel(open) {
|
|
|
408
554
|
${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
|
|
409
555
|
</div></section>`;
|
|
410
556
|
}
|
|
557
|
+
let pushOn = null; // null: encara no ho sé
|
|
558
|
+
async function refreshPush() {
|
|
559
|
+
let on = false;
|
|
560
|
+
try {
|
|
561
|
+
if ('serviceWorker' in navigator && 'PushManager' in window && Notification.permission === 'granted') {
|
|
562
|
+
const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription();
|
|
563
|
+
on = !!sub;
|
|
564
|
+
if (sub && state.connected) api('/api/push', { method: 'POST', body: sub.toJSON() }).catch(() => {});
|
|
565
|
+
}
|
|
566
|
+
} catch { /* sense avisos */ }
|
|
567
|
+
if (on !== pushOn) { pushOn = on; if (state.route.name === 'settings' || state.route.name === 'ajustos') render(); }
|
|
568
|
+
}
|
|
569
|
+
async function disablePush() {
|
|
570
|
+
try { const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); await sub?.unsubscribe(); } catch { /* res */ }
|
|
571
|
+
pushOn = false; toast('Avisos desactivats en aquest dispositiu'); render();
|
|
572
|
+
}
|
|
411
573
|
function viewSettings() {
|
|
412
574
|
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i veu.</p></div></div>';
|
|
413
|
-
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>' : ''}<div class="row"
|
|
414
|
-
|
|
575
|
+
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>` : '';
|
|
576
|
+
if (machine() && pushOn === null) refreshPush();
|
|
577
|
+
if (machine() && tgState === undefined) loadTelegram();
|
|
578
|
+
return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
415
579
|
}
|
|
416
580
|
function viewHelp() {
|
|
417
581
|
return `<div class="top"><div><h1>Ajuda</h1><p>Com es fa cada cosa i què mirar si no connecta.</p></div><button class="btn ink" type="button" data-act="tour">Fes el tutorial</button></div><div style="max-width:720px">${helpPanel()}</div>`;
|
|
@@ -430,8 +594,11 @@ function viewComputers() {
|
|
|
430
594
|
<div class="stack">
|
|
431
595
|
<section class="panel"><header><h2>Agents</h2><button class="btn sm ghost" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar</button></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Carregant…</p>'}</div></section>
|
|
432
596
|
${info ? `<section class="panel"><header><h2>Sistema</h2></header><div class="body"><dl class="kv"><dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió de nio</dt><dd>${esc(info.version)}</dd></dl></div></section>` : ''}
|
|
597
|
+
${info?.device ? `<section class="panel"><header><h2>Dispositius amb accés</h2></header><div class="body stack" id="devList">${devicesHtml(devCache.get(m.url))}</div></section>` : ''}
|
|
433
598
|
</div>
|
|
434
599
|
</div>` : '';
|
|
600
|
+
if (detail && info?.device) loadDevices();
|
|
601
|
+
if (ms.length > 1) probeAll();
|
|
435
602
|
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
|
|
436
603
|
}
|
|
437
604
|
|
|
@@ -534,7 +701,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
534
701
|
if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
|
|
535
702
|
if (e.target.closest('[data-scan]')) {
|
|
536
703
|
const v = $('#scanVideo', sh); v.hidden = false;
|
|
537
|
-
try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet();
|
|
704
|
+
try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); toast('Connectant…'); pairAndAdd(pair); go('#/sessions'); } }); }
|
|
538
705
|
catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
|
|
539
706
|
}
|
|
540
707
|
};
|
|
@@ -556,7 +723,7 @@ function renameSheet(i) {
|
|
|
556
723
|
const m = store.machines[i]; if (!m) return;
|
|
557
724
|
openSheet(`<h2>Canvia el nom</h2><p class="muted small">Només el veus tu, en aquest dispositiu. L’ordinador es continua dient «${esc(m.name)}» a la resta.</p>
|
|
558
725
|
<form class="field-row" id="renameForm"><input type="text" id="renameIn" value="${esc(m.name)}" maxlength="40" autocomplete="off" spellcheck="false" required aria-label="Nom de l’ordinador"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
|
|
559
|
-
const inp = $('#renameIn', sh); setTimeout(() => { inp.focus(); inp.select(); }, 60);
|
|
726
|
+
const inp = $('#renameIn', sh); if (!isTouch) setTimeout(() => { inp.focus(); inp.select(); }, 60);
|
|
560
727
|
$('#renameForm', sh).onsubmit = (e) => {
|
|
561
728
|
e.preventDefault(); e.stopPropagation();
|
|
562
729
|
const v = inp.value.trim(); if (!v) return;
|
|
@@ -577,25 +744,236 @@ function stateChip(s) {
|
|
|
577
744
|
if (s.state === 'error') return '<span class="chip error">Error</span>';
|
|
578
745
|
return '';
|
|
579
746
|
}
|
|
747
|
+
let manage = null; // Set d'ids seleccionats quan el mode «Gestiona» és actiu
|
|
748
|
+
const DAY = 864e5, money = (n) => (n ? `${n < 0.01 ? '<0,01' : n.toFixed(2).replace('.', ',')} $` : '0 $');
|
|
749
|
+
const manageBar = (list) => {
|
|
750
|
+
const n = manage.size, cost = list.filter((s) => manage.has(s.id)).reduce((t, s) => t + (s.cost || 0), 0);
|
|
751
|
+
return `<div class="manage-bar"><div class="row"><span class="muted small">Selecciona</span><button class="chip" type="button" data-man="all">Totes</button><button class="chip" type="button" data-man="idle">Acabades</button><button class="chip" type="button" data-man="old">Més de 30 dies</button><button class="chip" type="button" data-man="err">Amb error</button><button class="chip" type="button" data-man="none">Cap</button></div>
|
|
752
|
+
<div class="row between"><span class="muted small">${n ? `${n} seleccionades · ${money(cost)} gastats` : `${list.length} sessions · ${money(list.reduce((t, s) => t + (s.cost || 0), 0))} en total`}</span><span class="row"><button class="btn ghost" type="button" data-act="manage-off">Fet</button><button class="btn danger" type="button" data-act="manage-del" ${n ? '' : 'disabled'}>Esborra${n ? ` (${n})` : ''}</button></span></div></div>`;
|
|
753
|
+
};
|
|
754
|
+
function manageSelect(kind) {
|
|
755
|
+
const list = [...state.sessions.values()], now = Date.now();
|
|
756
|
+
const f = { all: () => true, none: () => false, idle: (x) => x.state === 'idle' && !x.pending, old: (x) => now - x.updated > 30 * DAY && x.state !== 'running', err: (x) => x.state === 'error' }[kind];
|
|
757
|
+
manage = new Set(list.filter(f).map((x) => x.id)); render();
|
|
758
|
+
}
|
|
759
|
+
async function manageDelete() {
|
|
760
|
+
const ids = [...manage]; if (!ids.length) return;
|
|
761
|
+
const run = ids.filter((id) => ['running', 'waiting'].includes(state.sessions.get(id)?.state)).length;
|
|
762
|
+
if (!confirm(`S’esborraran ${ids.length} converses${run ? ` (${run} encara treballen i s’aturaran)` : ''}. Els fitxers dels projectes no es toquen.`)) return;
|
|
763
|
+
let fail = 0;
|
|
764
|
+
await Promise.all(ids.map((id) => api(`/api/sessions/${id}`, { method: 'DELETE' }).then(() => handle({ type: 'removed', session: id })).catch(() => { fail++; })));
|
|
765
|
+
manage = fail ? new Set(ids.filter((id) => state.sessions.has(id))) : null;
|
|
766
|
+
toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
|
|
767
|
+
}
|
|
580
768
|
function viewSessions() {
|
|
581
769
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
582
770
|
const many = store.machines.length > 1, mach = machine();
|
|
583
771
|
const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
|
|
584
772
|
const hint = mobileHint();
|
|
585
|
-
const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
|
|
773
|
+
const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><span class="row">${list.length && state.connected && !manage ? '<button class="btn" data-act="manage-on" type="button">Gestiona</button>' : ''}${manage ? '' : `<button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button>`}</span></div>${hint}${manage && list.length ? manageBar(list) : ''}`;
|
|
774
|
+
if (machine() && !state.synced && !state.lastError && !list.length) return `${head}<div class="list" aria-busy="true">${[0, 1, 2].map(() => '<div class="card skel-card"><i class="skel" style="width:34%"></i><i class="skel tall" style="width:78%"></i><i class="skel" style="width:56%"></i></div>').join('')}</div>`;
|
|
586
775
|
if (!state.connected && !list.length) {
|
|
587
776
|
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
588
777
|
}
|
|
778
|
+
if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
|
|
589
779
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
590
|
-
|
|
780
|
+
<${manage ? 'div role="checkbox" tabindex="0" aria-checked="' + manage.has(s.id) + '" data-pick="' + esc(s.id) + '"' : 'a href="#/s/' + esc(s.id) + '"'} class="card session-card ${manage ? 'pick-mode' : ''} ${manage?.has(s.id) ? 'picked' : ''}">
|
|
591
781
|
<div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
|
|
592
782
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
593
783
|
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
|
|
594
|
-
<div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span
|
|
595
|
-
|
|
784
|
+
<div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span>${s.cost ? `<span>·</span><span>${money(s.cost)}</span>` : ''}</div>
|
|
785
|
+
</${manage ? 'div' : 'a'}>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
|
|
596
786
|
}
|
|
597
787
|
|
|
598
788
|
/* ---------- Aprovacions */
|
|
789
|
+
/* ---------- Dispositius amb accés */
|
|
790
|
+
const devCache = new Map(); // adreça de l'ordinador -> llista
|
|
791
|
+
let devLoadAt = 0;
|
|
792
|
+
function devicesHtml(devs) {
|
|
793
|
+
if (!devs) return '<p class="muted small">Carregant…</p>';
|
|
794
|
+
return devs.map((d) => `<div class="agent-row"><span class="grow"><div class="row"><b>${esc(d.name)}</b>${d.current ? '<span class="chip">aquest</span>' : ''}</div><div class="muted small">${d.primary ? 'La fa servir el panell que obre nio i «nio ssh». Es canvia amb nio reset-token.' : `${d.lastSeen ? `Vist ${ago(d.lastSeen)}` : 'Encara no s’ha connectat'} · aparellat ${new Date(d.created).toLocaleDateString('ca')}`}</div></span>${d.primary ? '' : `<button class="btn sm danger" data-act="revoke" data-dev="${esc(d.id)}" type="button">${d.current ? 'Desconnecta' : 'Revoca'}</button>`}</div>`).join('') + '<p class="muted small">Revocar un dispositiu li treu l’accés a l’instant. Cada mòbil que s’aparella amb un codi en té un de propi.</p>';
|
|
795
|
+
}
|
|
796
|
+
async function loadDevices(force) {
|
|
797
|
+
const m = machine(); if (!m || (!force && Date.now() - devLoadAt < 10000)) return;
|
|
798
|
+
devLoadAt = Date.now();
|
|
799
|
+
try { devCache.set(m.url, await api('/api/devices')); } catch { devCache.set(m.url, []); }
|
|
800
|
+
const box = $('#devList'); if (box) box.innerHTML = devicesHtml(devCache.get(m.url));
|
|
801
|
+
}
|
|
802
|
+
async function revokeDevice(id) {
|
|
803
|
+
const m = machine(), d = devCache.get(m?.url)?.find((x) => x.id === id);
|
|
804
|
+
if (!m || !confirm(d?.current ? 'Aquest dispositiu deixarà de tenir accés a aquest ordinador.' : `«${d?.name || 'Aquest dispositiu'}» deixarà de tenir accés a aquest ordinador a l’instant.`)) return;
|
|
805
|
+
try {
|
|
806
|
+
await api(`/api/devices/${id}`, { method: 'DELETE' }); toast('Accés revocat');
|
|
807
|
+
if (d?.current) return forgetMachine(store.active);
|
|
808
|
+
loadDevices(true);
|
|
809
|
+
} catch (e) { toast(e.message); }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/* ---------- Activitat: registre d'accions de l'ordinador (què, per què, quan i qui) */
|
|
813
|
+
let activity = null, actFilter = 'tot';
|
|
814
|
+
const ACT_FILTERS = { tot: 'Tot', approval: 'Aprovacions', tool: 'Eines', job: 'Feines', device: 'Dispositius' };
|
|
815
|
+
function viewActivity() {
|
|
816
|
+
return `<div class="top"><div><h1>Activitat</h1><p>Tot el que han fet els agents en aquest ordinador: què, per què, quan i qui ho ha aprovat. Es guarda només a l’ordinador.</p></div><button class="btn" data-act="activity-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div>
|
|
817
|
+
<div class="seg act-filter" role="group">${Object.entries(ACT_FILTERS).map(([k, l]) => `<button type="button" data-filter="${k}" aria-pressed="${k === actFilter}">${l}</button>`).join('')}</div>
|
|
818
|
+
<div id="actList">${activityHtml()}</div>`;
|
|
819
|
+
}
|
|
820
|
+
function activityHtml() {
|
|
821
|
+
if (!activity) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
822
|
+
const list = activity.filter((e) => actFilter === 'tot' || e.type === actFilter);
|
|
823
|
+
if (!list.length) return `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha res</h2><p>Quan els agents treballin, aquí en veuràs cada pas.</p></div>`;
|
|
824
|
+
const VERD = { allowed: '<span class="verdict allowed">✓ Aprovat</span>', denied: '<span class="verdict denied">✕ Rebutjat</span>' };
|
|
825
|
+
let day = '';
|
|
826
|
+
return `<div class="act">${list.map((e) => {
|
|
827
|
+
const d = new Date(e.t).toLocaleDateString('ca', { weekday: 'long', day: 'numeric', month: 'long' });
|
|
828
|
+
const head = d !== day ? `<div class="act-day">${esc((day = d))}</div>` : '';
|
|
829
|
+
const when = new Date(e.t).toLocaleTimeString('ca', { hour: '2-digit', minute: '2-digit' });
|
|
830
|
+
const who = e.type === 'approval' ? `${VERD[e.decision] || ''} <span class="muted small">per ${esc(e.by || '—')}</span>` : e.type === 'tool' ? `<span class="muted small">${esc(MODES[e.mode]?.label || e.mode || '')}</span>` : e.by ? `<span class="muted small">${esc(e.by)}</span>` : '';
|
|
831
|
+
return `${head}<div class="act-row act-${esc(e.type)}"><span class="act-t mono">${when}</span><div class="grow"><div class="row">${e.agent ? `<span class="agent-tag" data-agent="${esc(e.agent)}">${esc(AGENT_NAMES[e.agent] || e.agent)}</span>` : ''}${e.tool ? `<b>${esc(e.tool)}</b>` : ''}${who}</div><div class="mono small act-what">${esc(e.what || '')}</div>${e.why ? `<div class="muted small">Motiu: ${esc(e.why)}</div>` : ''}${e.session ? `<a class="muted small" href="#/s/${esc(e.session)}">${esc(e.title || 'Obre la sessió')}</a>` : ''}</div></div>`;
|
|
832
|
+
}).join('')}</div>`;
|
|
833
|
+
}
|
|
834
|
+
async function loadActivity() {
|
|
835
|
+
try { activity = await api('/api/audit?limit=300'); } catch (e) { activity = []; toast(e.message); }
|
|
836
|
+
const box = $('#actList'); if (box) box.innerHTML = activityHtml();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/* ---------- Memòria compartida (fitxers markdown a l'ordinador, per a Claude Code i Codex) */
|
|
840
|
+
let mem = null;
|
|
841
|
+
function viewMemory() {
|
|
842
|
+
return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents en aquest ordinador. Claude Code i Codex la llegeixen i hi escriuen (servidor MCP de nio). Són fitxers markdown a <span class="mono">~/.nio/memory</span>.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
|
|
843
|
+
<form class="row mem-search" id="memSearch" style="flex-wrap:nowrap"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria"><button class="btn" type="submit">Busca</button></form>
|
|
844
|
+
<div id="memBody">${memHtml()}</div>`;
|
|
845
|
+
}
|
|
846
|
+
function memHtml() {
|
|
847
|
+
if (!mem) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
848
|
+
if (mem.error) return `<div class="panel empty">${bot('sleep')}<h2>${esc(mem.error)}</h2><p>Cal nio 0.6 o més nou en aquest ordinador.</p></div>`;
|
|
849
|
+
if (mem.hits) return `<div class="list">${mem.hits.length ? mem.hits.map((h) => `<button class="card" type="button" data-mem="${esc(h.path)}"><b class="mono small">${esc(h.path)}:${h.line}</b><div class="muted small">${esc(h.text)}</div></button>`).join('') : '<p class="muted">Cap resultat.</p>'}<button class="btn ghost" data-act="mem-all" type="button">Mostra tots els fitxers</button></div>`;
|
|
850
|
+
const groups = {};
|
|
851
|
+
for (const f of mem.files) (groups[f.path.includes('/') ? f.path.split('/')[0] : ''] ||= []).push(f);
|
|
852
|
+
return Object.entries(groups).map(([g, fs]) => `${g ? `<div class="section-title">${esc(g)}</div>` : ''}<div class="list">${fs.map((f) => `<button class="card row" type="button" data-mem="${esc(f.path)}" style="flex-wrap:nowrap"><span class="grow"><b>${esc(f.path.split('/').pop().replace(/\.md$/, ''))}</b><div class="muted small">${ago(f.updated)} · ${bytes(f.size)}</div></span></button>`).join('')}</div>`).join('') || '<p class="muted">La memòria és buida.</p>';
|
|
853
|
+
}
|
|
854
|
+
async function loadMemory() {
|
|
855
|
+
try { mem = await api('/api/memory'); } catch (e) { mem = { error: e.message }; }
|
|
856
|
+
const box = $('#memBody'); if (box) box.innerHTML = memHtml();
|
|
857
|
+
}
|
|
858
|
+
async function memEdit(p) {
|
|
859
|
+
let f = { path: p || 'projectes/', content: '' };
|
|
860
|
+
if (p) { try { f = await api(`/api/memory/f/${encodeURIComponent(p)}`); } catch (e) { return toast(e.message); } }
|
|
861
|
+
openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2>
|
|
862
|
+
${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" autocapitalize="off" spellcheck="false"></label>'}
|
|
863
|
+
<label class="f"><span>Contingut</span><textarea class="field mono" id="memText" rows="14" spellcheck="false">${esc(f.content)}</textarea></label>
|
|
864
|
+
<div class="row between sheet-foot">${p ? '<button class="btn danger" type="button" id="memDel">Esborra</button>' : '<span></span>'}<button class="btn ink" type="button" id="memSave">Desa</button></div>`, (sh) => {
|
|
865
|
+
$('#memSave', sh).onclick = async () => {
|
|
866
|
+
const path = p || $('#memPath', sh).value.trim();
|
|
867
|
+
try { await api(`/api/memory/f/${encodeURIComponent(path)}`, { method: 'PUT', body: { content: $('#memText', sh).value } }); toast('Desat'); closeSheet(); mem = null; loadMemory(); }
|
|
868
|
+
catch (e) { toast(e.message, 4200); }
|
|
869
|
+
};
|
|
870
|
+
if (p) $('#memDel', sh).onclick = async () => {
|
|
871
|
+
if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
|
|
872
|
+
try { await api(`/api/memory/f/${encodeURIComponent(p)}`, { method: 'DELETE' }); toast('Esborrat'); closeSheet(); loadMemory(); } catch (e) { toast(e.message); }
|
|
873
|
+
};
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
document.addEventListener('click', (e) => { const b = e.target.closest('[data-mem]'); if (b) memEdit(b.dataset.mem); });
|
|
877
|
+
document.addEventListener('submit', async (e) => {
|
|
878
|
+
if (e.target.id !== 'memSearch') return;
|
|
879
|
+
e.preventDefault(); const q = $('#memQ').value.trim(); if (!q) return loadMemory();
|
|
880
|
+
try { mem = { ...(mem || {}), hits: await api(`/api/memory/search?q=${encodeURIComponent(q)}`) }; } catch (err) { toast(err.message); }
|
|
881
|
+
$('#memBody').innerHTML = memHtml();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
/* ---------- Feines: cua, cadenes i programació (l'orquestrador viu a l'ordinador) */
|
|
885
|
+
let jobsData = null;
|
|
886
|
+
const JOB_CHIP = { pendent: '', 'en curs': 'running', feta: 'ok', fallada: 'error', 'cancel·lada': '', programada: 'waiting' };
|
|
887
|
+
const CRON_PRESETS = [['', 'Només una vegada'], ['0 9 * * *', 'Cada dia a les 9'], ['0 9 * * 1-5', 'Dies feiners a les 9'], ['0 * * * *', 'Cada hora'], ['0 8 * * 1', 'Cada dilluns a les 8'], ['custom', 'Personalitzada (cron)…']];
|
|
888
|
+
function jobWhen(j) {
|
|
889
|
+
if (j.state === 'programada') return `${CRON_PRESETS.find(([c]) => c === j.cron)?.[1] || `cron ${j.cron}`} · propera ${j.runAt ? new Date(j.runAt).toLocaleString('ca', { weekday: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}`;
|
|
890
|
+
if (j.state === 'pendent' && j.runAt && j.runAt > Date.now()) return `Començarà ${new Date(j.runAt).toLocaleString('ca', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}`;
|
|
891
|
+
if (j.state === 'pendent' && j.after) return `Quan acabi «${jobsData?.jobs.find((x) => x.id === j.after)?.title || 'la feina anterior'}»`;
|
|
892
|
+
if (j.finished) return ago(j.finished);
|
|
893
|
+
return j.started ? `Des de ${ago(j.started)}` : 'A la cua';
|
|
894
|
+
}
|
|
895
|
+
function jobCard(j) {
|
|
896
|
+
const live = j.state === 'en curs' || j.state === 'pendent' || j.state === 'programada';
|
|
897
|
+
return `<div class="card job" data-job="${esc(j.id)}">
|
|
898
|
+
<div class="row between"><span class="row"><span class="agent-tag" data-agent="${esc(j.agent)}">${esc(AGENT_NAMES[j.agent] || j.agent)}</span>${j.after ? '<span class="chip">encadenada</span>' : ''}${j.rule ? `<span class="chip" title="Regla d’encaminament">regla</span>` : ''}</span><span class="chip ${JOB_CHIP[j.state] || ''}">${j.state === 'en curs' ? '<i class="pulse"></i>' : ''}${esc(j.state)}</span></div>
|
|
899
|
+
<div class="title">${esc(j.title)}</div>
|
|
900
|
+
<div class="meta"><span class="mono">${esc(basename(j.cwd))}</span><span>·</span><span>${esc(MODES[j.mode]?.label || '')}</span><span>·</span><span>${esc(jobWhen(j))}</span></div>
|
|
901
|
+
${j.error && j.state !== 'feta' ? `<div class="muted small" style="margin-top:6px">${esc(j.error)}</div>` : ''}
|
|
902
|
+
<div class="row" style="margin-top:10px">${j.session ? `<a class="btn sm" href="#/s/${esc(j.session)}">Obre la sessió</a>` : ''}${j.state === 'pendent' || j.state === 'programada' ? `<button class="btn sm" data-job-act="run" type="button">${j.state === 'programada' ? 'Executa ara' : 'Comença ja'}</button>` : ''}${live ? `<button class="btn sm ghost" data-job-act="cancel" type="button">${j.state === 'programada' ? 'Atura la programació' : 'Cancel·la'}</button>` : `<button class="btn sm ghost" data-job-act="del" type="button">Treu de la llista</button>`}</div>
|
|
903
|
+
</div>`;
|
|
904
|
+
}
|
|
905
|
+
function viewJobs() {
|
|
906
|
+
const head = `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia, i encadena-les («quan Claude Code acabi, que Codex ho revisi»). Les fan Claude Code o Codex a l’ordinador.</p></div><button class="btn ink" data-act="job-new" type="button">${svg(ICON.plus)}Nova feina</button></div>`;
|
|
907
|
+
if (!jobsData) return `${head}<div id="jobsBody"><div class="panel empty">${bot('work')}<h2>Carregant…</h2></div></div>`;
|
|
908
|
+
return `${head}<div id="jobsBody">${jobsHtml()}</div>`;
|
|
909
|
+
}
|
|
910
|
+
function jobsHtml() {
|
|
911
|
+
if (jobsData.error) return `<div class="panel empty">${bot('sleep')}<h2>${esc(jobsData.error)}</h2><p>Cal nio 0.6 o més nou en aquest ordinador.</p></div>`;
|
|
912
|
+
const js = jobsData.jobs, sched = js.filter((j) => j.state === 'programada'), act = js.filter((j) => j.state === 'en curs' || j.state === 'pendent'), done = js.filter((j) => !['programada', 'en curs', 'pendent'].includes(j.state)).slice(0, 30);
|
|
913
|
+
const sec = (t, l) => (l.length ? `<div class="section-title">${t}</div><div class="list">${l.map(jobCard).join('')}</div>` : '');
|
|
914
|
+
return (sec('En marxa i a la cua', act) + sec('Programades', sched) + sec('Acabades', done)) || `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap feina</h2><p>Per exemple: «Cada dia feiner a les 9, revisa les issues noves i fes-me’n un resum».</p><button class="btn ink" data-act="job-new" type="button">${svg(ICON.plus)}Nova feina</button></div>`;
|
|
915
|
+
}
|
|
916
|
+
async function loadJobs() {
|
|
917
|
+
try { jobsData = await api('/api/jobs'); } catch (e) { jobsData = { error: e.message, jobs: [] }; }
|
|
918
|
+
const box = $('#jobsBody'); if (box) box.innerHTML = jobsHtml();
|
|
919
|
+
}
|
|
920
|
+
function onJob(msg) {
|
|
921
|
+
if (!jobsData || jobsData.error) return;
|
|
922
|
+
if (msg.removed) jobsData.jobs = jobsData.jobs.filter((j) => j.id !== msg.removed);
|
|
923
|
+
else { const i = jobsData.jobs.findIndex((j) => j.id === msg.job.id); if (i >= 0) jobsData.jobs[i] = msg.job; else jobsData.jobs.unshift(msg.job); }
|
|
924
|
+
const box = $('#jobsBody'); if (box) box.innerHTML = jobsHtml();
|
|
925
|
+
}
|
|
926
|
+
async function newJobSheet() {
|
|
927
|
+
let projects = [];
|
|
928
|
+
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
929
|
+
const agents = (state.info?.agents || []).filter((a) => a.available && ['claude', 'codex'].includes(a.id));
|
|
930
|
+
const prev = (jobsData?.jobs || []).filter((j) => ['pendent', 'en curs', 'feta'].includes(j.state)).slice(0, 15);
|
|
931
|
+
const sel = { agent: 'auto', mode: 'preguntar', when: 'ara' };
|
|
932
|
+
openSheet(`<h2>Nova feina</h2>
|
|
933
|
+
<label class="f"><span>Què ha de fer?</span><textarea class="field" id="jobPrompt" rows="3" placeholder="Per exemple: revisa les issues noves i fes-me’n un resum"></textarea></label>
|
|
934
|
+
<div class="grid-2"><label class="f"><span>Agent</span><select id="jobAgent"><option value="auto">Automàtic (regles)</option>${agents.map((a) => `<option value="${a.id}">${esc(a.name)}</option>`).join('')}</select></label>
|
|
935
|
+
<label class="f"><span>Carpeta</span><select id="jobCwd">${projects.map((p) => `<option value="${esc(p.path)}">${esc(p.name)}</option>`).join('')}</select></label></div>
|
|
936
|
+
<div class="stack"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="jobMode" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-jmode="${k}" aria-pressed="${k === sel.mode}">${m.label}</button>`).join('')}</div></div>
|
|
937
|
+
<div class="grid-2"><label class="f"><span>Quan</span><select id="jobWhen"><option value="ara">Ara</option><option value="mes">Més tard…</option>${prev.length ? '<option value="after">Quan acabi una altra feina…</option>' : ''}<option value="cron">Periòdicament…</option></select></label>
|
|
938
|
+
<div id="jobWhenX"></div></div>
|
|
939
|
+
${agents.some((a) => a.id === 'codex') ? '<label class="row check"><input type="checkbox" id="jobReview"> <span>Quan acabi, que Codex revisi els canvis</span></label>' : ''}
|
|
940
|
+
<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="jobGo" type="button">Posa-la a la cua</button></div>`, (sh) => {
|
|
941
|
+
const x = $('#jobWhenX', sh);
|
|
942
|
+
$('#jobWhen', sh).onchange = (e) => {
|
|
943
|
+
sel.when = e.target.value;
|
|
944
|
+
x.innerHTML = sel.when === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
|
|
945
|
+
: sel.when === 'after' ? `<label class="f"><span>Després de</span><select id="jobAfter">${prev.map((j) => `<option value="${esc(j.id)}">${esc(j.title)} (${esc(j.state)})</option>`).join('')}</select></label>`
|
|
946
|
+
: sel.when === 'cron' ? `<label class="f"><span>Cada quan</span><select id="jobCron">${CRON_PRESETS.slice(1).map(([c, l]) => `<option value="${c}">${l}</option>`).join('')}</select></label><input type="text" id="jobCronX" placeholder="min hora dia mes dia-setmana" hidden autocapitalize="off" spellcheck="false" class="mono">` : '';
|
|
947
|
+
const c = $('#jobCron', sh); if (c) c.onchange = () => ($('#jobCronX', sh).hidden = c.value !== 'custom');
|
|
948
|
+
};
|
|
949
|
+
sh.addEventListener('click', (e) => { const m = e.target.closest('[data-jmode]'); if (m) { sel.mode = m.dataset.jmode; $$('[data-jmode]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.jmode === sel.mode)); } if (e.target.closest('[data-close]')) closeSheet(); });
|
|
950
|
+
$('#jobGo', sh).onclick = async () => {
|
|
951
|
+
const prompt = $('#jobPrompt', sh).value.trim(); if (!prompt) return toast('Explica què ha de fer la feina');
|
|
952
|
+
const body = { prompt, agent: $('#jobAgent', sh).value, cwd: $('#jobCwd', sh).value, mode: sel.mode };
|
|
953
|
+
if (sel.when === 'mes') { const v = $('#jobAt', sh)?.value; if (!v) return toast('Tria el dia i l’hora'); body.at = new Date(v).getTime(); }
|
|
954
|
+
if (sel.when === 'after') body.after = $('#jobAfter', sh).value;
|
|
955
|
+
if (sel.when === 'cron') body.cron = $('#jobCron', sh).value === 'custom' ? $('#jobCronX', sh).value.trim() : $('#jobCron', sh).value;
|
|
956
|
+
$('#jobGo', sh).disabled = true;
|
|
957
|
+
try {
|
|
958
|
+
const j = await api('/api/jobs', { method: 'POST', body });
|
|
959
|
+
if ($('#jobReview', sh)?.checked) await api('/api/jobs', { method: 'POST', body: { agent: 'codex', cwd: j.cwd, mode: 'llegir', after: j.id, title: `Revisió: ${j.title}`, prompt: 'Revisa els canvis que s’acaben de fer en aquesta carpeta (git diff i fitxers nous). Explica què està bé, què es pot millorar i si hi ha cap error. Context de la feina anterior:\n\n{{anterior}}' } });
|
|
960
|
+
closeSheet(); toast(body.cron ? 'Feina programada' : 'Feina a la cua'); loadJobs();
|
|
961
|
+
} catch (e) { toast(e.message, 4200); $('#jobGo', sh).disabled = false; }
|
|
962
|
+
};
|
|
963
|
+
if (!isTouch) setTimeout(() => $('#jobPrompt', sh)?.focus(), 80);
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
document.addEventListener('click', async (e) => {
|
|
967
|
+
const b = e.target.closest('[data-job-act]'); if (!b) return;
|
|
968
|
+
const id = b.closest('[data-job]').dataset.job, act = b.dataset.jobAct;
|
|
969
|
+
try {
|
|
970
|
+
if (act === 'run') await api(`/api/jobs/${id}/run`, { method: 'POST' });
|
|
971
|
+
if (act === 'cancel') await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
|
972
|
+
if (act === 'del') await api(`/api/jobs/${id}`, { method: 'DELETE' });
|
|
973
|
+
loadJobs();
|
|
974
|
+
} catch (err) { toast(err.message); }
|
|
975
|
+
});
|
|
976
|
+
|
|
599
977
|
function approvalCard(a, showSession = true) {
|
|
600
978
|
const decided = a.state && a.state !== 'pending';
|
|
601
979
|
if (decided && !showSession) {
|
|
@@ -607,13 +985,15 @@ function approvalCard(a, showSession = true) {
|
|
|
607
985
|
${showSession ? `<div class="row between"><span class="agent-tag" data-agent="${esc(a.agent)}">${esc(AGENT_NAMES[a.agent] || a.agent)}</span><a class="btn sm" href="#/s/${esc(a.session)}">Obre la sessió</a></div>${a.title ? `<div class="muted small">${esc(a.title)}</div>` : ''}` : ''}
|
|
608
986
|
<div class="q">Vol fer servir <span class="mono">${esc(a.tool)}</span></div>
|
|
609
987
|
<pre>${esc(prettyInput(a.input))}</pre>
|
|
988
|
+
${a.reason ? `<div class="why"><span class="muted small">Motiu</span><p>${esc(a.reason)}</p></div>` : ''}
|
|
989
|
+
${a.shot ? `<img class="shot" alt="Captura de la pantalla quan ho ha demanat" data-shot="${esc(a.shot)}">` : ''}
|
|
610
990
|
${decided ? `<div class="verdict ${esc(a.state)}">${a.state === 'allowed' ? '✓ Ho has aprovat' : '✕ Ho has rebutjat'}</div>` : '<div class="actions"><button class="btn danger" data-decide="deny" type="button">Rebutja</button><button class="btn ok" data-decide="allow" type="button">Aprova</button></div>'}
|
|
611
991
|
</div>`;
|
|
612
992
|
}
|
|
613
993
|
function viewApprovals() {
|
|
614
994
|
const list = [...state.approvals.values()].sort((a, b) => a.created - b.created);
|
|
615
995
|
return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>
|
|
616
|
-
${list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
|
|
996
|
+
${machine() && !state.synced && !state.lastError && !list.length ? '<div class="list" aria-busy="true"><div class="card skel-card"><i class="skel" style="width:40%"></i><i class="skel tall" style="width:80%"></i><i class="skel" style="width:60%"></i></div></div>' : list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
|
|
617
997
|
}
|
|
618
998
|
// Marca una aprovació com a resolta a tot arreu: a la llista, a la conversa i a l'esdeveniment desat.
|
|
619
999
|
function markDecided(id, st) {
|
|
@@ -663,10 +1043,10 @@ function renderSession() {
|
|
|
663
1043
|
feedEls = new Map(); toolEls = new Map();
|
|
664
1044
|
renderSessionHead();
|
|
665
1045
|
const feed = $('#feed'), evs = state.events.get(id);
|
|
666
|
-
if (!evs) feed.innerHTML =
|
|
1046
|
+
if (!evs) feed.innerHTML = machine() ? '<div class="skel-feed" aria-busy="true"><i class="skel r" style="width:46%"></i><i class="skel" style="width:88%;height:64px"></i><i class="skel" style="width:70%;height:40px"></i></div>' : `<div class="ev-info">${ 'Connecta un ordinador per veure aquesta sessió.'}</div>`;
|
|
667
1047
|
else for (const e of [...evs.values()].sort((a, b) => a.seq - b.seq)) putEvent(e, false);
|
|
668
1048
|
renderTyping(); renderComposer();
|
|
669
|
-
requestAnimationFrame(() => (feed
|
|
1049
|
+
requestAnimationFrame(() => toBottom(feed));
|
|
670
1050
|
}
|
|
671
1051
|
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
672
1052
|
function renderSessionHead() {
|
|
@@ -679,10 +1059,10 @@ function renderSessionHead() {
|
|
|
679
1059
|
if (!s) { bar.innerHTML = ''; return; }
|
|
680
1060
|
const ag = state.info?.agents?.find((a) => a.id === s.agent);
|
|
681
1061
|
bar.innerHTML = `<span class="chip"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
|
|
682
|
-
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
|
|
683
|
-
<button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${esc(basename(s.cwd))}</button>
|
|
684
|
-
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
|
|
685
|
-
${s.cost ? `<span class="chip mono">${s.cost.toFixed(2)} $</span>` : ''}
|
|
1062
|
+
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${svg(CHIP_ICON.mode)}<span>${esc(MODES[s.mode]?.label || s.mode)}</span></button>`}
|
|
1063
|
+
<button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${svg(CHIP_ICON.folder)}<span>${esc(basename(s.cwd))}</span></button>
|
|
1064
|
+
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${svg(CHIP_ICON.model)}<span>${esc(s.model || 'Model per defecte')}</span></button>
|
|
1065
|
+
${s.cost ? `<span class="chip mono">${svg(CHIP_ICON.cost)}<span>${s.cost.toFixed(2)} $</span></span>` : ''}
|
|
686
1066
|
${stateChip(s)}`;
|
|
687
1067
|
}
|
|
688
1068
|
// Alçada del camp de text segons el contingut, sense barra de desplaçament fins al límit.
|
|
@@ -692,7 +1072,10 @@ function autogrow(ta, max = 180) {
|
|
|
692
1072
|
ta.style.height = `${Math.min(h, max)}px`;
|
|
693
1073
|
ta.style.overflowY = h > max ? 'auto' : 'hidden';
|
|
694
1074
|
}
|
|
695
|
-
|
|
1075
|
+
// Al mòbil fa scroll el document sencer; a l'escriptori, la conversa té el seu propi espai.
|
|
1076
|
+
const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
|
|
1077
|
+
const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
|
|
1078
|
+
const toBottom = (f) => { const s = scroller(f); s.scrollTop = s.scrollHeight; };
|
|
696
1079
|
function putEvent(e, scroll = true) {
|
|
697
1080
|
const feed = $('#feed'); if (!feed) return;
|
|
698
1081
|
const stick = nearBottom(feed);
|
|
@@ -716,7 +1099,7 @@ function putEvent(e, scroll = true) {
|
|
|
716
1099
|
feedEls.set(e.seq, node);
|
|
717
1100
|
if (e.kind === 'tool') toolEls.set(e.id, node);
|
|
718
1101
|
renderTyping();
|
|
719
|
-
if (scroll && stick) feed
|
|
1102
|
+
if (scroll && stick) toBottom(feed);
|
|
720
1103
|
}
|
|
721
1104
|
function eventNode(e) {
|
|
722
1105
|
const s = state.sessions.get(state.route.id);
|
|
@@ -789,11 +1172,24 @@ async function runCmd(s, name, arg) {
|
|
|
789
1172
|
case 'ajuda': return helpCmdsSheet(s);
|
|
790
1173
|
}
|
|
791
1174
|
}
|
|
1175
|
+
const folderKey = (k) => `nio.${k}.${machine()?.id || machine()?.url}`;
|
|
1176
|
+
const getFavs = () => LS.get(folderKey('favs'), []);
|
|
1177
|
+
function toggleFav(p) { const f = getFavs(), n = f.includes(p) ? f.filter((x) => x !== p) : [p, ...f]; LS.set(folderKey('favs'), n); return n.includes(p); }
|
|
1178
|
+
function rememberFolder(p) { const l = LS.get(folderKey('last'), {}); LS.set(folderKey('last'), { ...l, cwd: p, recent: [p, ...(l.recent || []).filter((x) => x !== p)].slice(0, 6) }); }
|
|
1179
|
+
// Última utilitzada, preferides i recents, sense repeticions.
|
|
1180
|
+
function quickFolders(extra = []) {
|
|
1181
|
+
const l = LS.get(folderKey('last'), {}), favs = getFavs(), seen = new Set(), out = [];
|
|
1182
|
+
const add = (path, tag) => { if (path && !seen.has(path)) { seen.add(path); out.push({ path, name: basename(path), tag }); } };
|
|
1183
|
+
add(l.cwd, 'last'); favs.forEach((p) => add(p, 'fav')); (l.recent || []).forEach((p) => add(p)); extra.forEach((p) => add(p.path));
|
|
1184
|
+
return out;
|
|
1185
|
+
}
|
|
1186
|
+
const folderChip = (f) => `<button type="button" class="chip ${f.tag === 'fav' ? 'fav' : ''}" data-fs="${esc(f.path)}" title="${esc(f.path)}">${f.tag === 'fav' ? '★ ' : f.tag === 'last' ? '↺ ' : ''}${esc(f.name)}</button>`;
|
|
1187
|
+
const favBtn = (p) => `<button class="chip" type="button" data-fav aria-pressed="${getFavs().includes(p)}">${getFavs().includes(p) ? '★ Preferida' : '☆ Desa com a preferida'}</button>`;
|
|
792
1188
|
function folderSheet(s) {
|
|
793
1189
|
let d = null;
|
|
794
1190
|
const draw = () => {
|
|
795
1191
|
const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
|
|
796
|
-
$('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
|
|
1192
|
+
$('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().slice(0, 8).map(folderChip).join('')}</div><div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
|
|
797
1193
|
<div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
|
|
798
1194
|
const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
|
|
799
1195
|
};
|
|
@@ -802,8 +1198,9 @@ function folderSheet(s) {
|
|
|
802
1198
|
sh.onclick = async (e) => {
|
|
803
1199
|
if (e.target.closest('[data-close]')) return closeSheet();
|
|
804
1200
|
const f = e.target.closest('[data-fs]'); if (f && !f.disabled) return go(f.dataset.fs);
|
|
1201
|
+
if (e.target.closest('[data-fav]') && d) { toggleFav(d.path); return draw(); }
|
|
805
1202
|
if (e.target.closest('#fsOk') && d) {
|
|
806
|
-
try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { cwd: d.path } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); cmdCache.delete(s.id); toast(`Carpeta: ${basename(sum.cwd)}`); } catch (er) { toast(er.message, 4200); }
|
|
1203
|
+
try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { cwd: d.path } }); state.sessions.set(s.id, sum); rememberFolder(sum.cwd); closeSheet(); renderSessionHead(); cmdCache.delete(s.id); toast(`Carpeta: ${basename(sum.cwd)}`); } catch (er) { toast(er.message, 4200); }
|
|
807
1204
|
}
|
|
808
1205
|
};
|
|
809
1206
|
draw(); go(s.cwd);
|
|
@@ -828,10 +1225,11 @@ function renderComposer() {
|
|
|
828
1225
|
const box = $('#composer'); if (!box) return;
|
|
829
1226
|
const s = state.sessions.get(state.route.id), busy = !!s && (s.state === 'running' || s.state === 'waiting');
|
|
830
1227
|
const prev = $('textarea', box)?.value || '';
|
|
831
|
-
|
|
832
|
-
box.dataset.busy
|
|
1228
|
+
const waiting = s?.state === 'waiting';
|
|
1229
|
+
if (box.dataset.busy === String(busy) + waiting && $('textarea', box)) return;
|
|
1230
|
+
box.dataset.busy = String(busy) + waiting;
|
|
833
1231
|
if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
|
|
834
|
-
box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
1232
|
+
box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${waiting ? 'Esperant que aprovis o rebutgis…' : busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
835
1233
|
const ta = $('#msg'); ta.value = prev;
|
|
836
1234
|
const grow = () => autogrow(ta);
|
|
837
1235
|
let items = [], sel = 0;
|
|
@@ -877,11 +1275,22 @@ function renderComposer() {
|
|
|
877
1275
|
/* ---------- Fulls */
|
|
878
1276
|
function openSheet(html, onMount, cls) {
|
|
879
1277
|
const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
|
|
880
|
-
if (!d.open) d.showModal(); raiseToasts();
|
|
1278
|
+
if (!d.open) d.showModal(); raiseToasts();
|
|
1279
|
+
if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
|
|
881
1280
|
onMount?.($('#sheetIn'));
|
|
882
1281
|
}
|
|
883
|
-
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
1282
|
+
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
|
|
1283
|
+
// A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
|
|
1284
|
+
function focusIn(root) { if (isTouch || !root) return; const t = root.querySelector('textarea:not([disabled]), input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([disabled])'); if (t && document.activeElement !== t) { t.focus({ preventScroll: true }); if (t.select && t.tagName === 'INPUT' && t.value && /^(text|search)$/.test(t.type)) { /* sense seleccionar: el cursor al final */ const n = t.value.length; try { t.setSelectionRange(n, n); } catch { /* res */ } } } }
|
|
1285
|
+
function focusMain() {
|
|
1286
|
+
if (isTouch || $('#sheet')?.open || $('#palette')?.open) return;
|
|
1287
|
+
const r = state.route.name;
|
|
1288
|
+
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1289
|
+
else if (r === 'mobil' && local.engine) $('#lmsg')?.focus({ preventScroll: true });
|
|
1290
|
+
else if (r === 'models') $('#pullIn')?.focus({ preventScroll: true });
|
|
1291
|
+
}
|
|
884
1292
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1293
|
+
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
885
1294
|
|
|
886
1295
|
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
887
1296
|
const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
|
|
@@ -959,10 +1368,10 @@ async function newSessionSheet(preset = {}) {
|
|
|
959
1368
|
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${[...agents].sort((x, y) => (y.available ? 1 : 0) - (x.available ? 1 : 0)).map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
|
|
960
1369
|
() => {
|
|
961
1370
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
962
|
-
const
|
|
1371
|
+
const qf = quickFolders(projects), favN = getFavs().length, quick = qf.filter((f) => f.tag).concat(qf.filter((f) => !f.tag).slice(0, 6)).slice(0, favN + 8).map(folderChip).join('');
|
|
963
1372
|
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
964
1373
|
<form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
|
|
965
|
-
<div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
|
|
1374
|
+
<div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}${favBtn(fsd?.path || sel.cwd)}</div>
|
|
966
1375
|
<div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
|
|
967
1376
|
${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
|
|
968
1377
|
: !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
|
|
@@ -1001,6 +1410,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
1001
1410
|
const ag = t.closest('[data-agent-id]');
|
|
1002
1411
|
if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); if (a.id !== sel.agent) { sel.agent = a.id; sel.model = ''; models = null; } step = 1; return draw(); }
|
|
1003
1412
|
const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
|
|
1413
|
+
if (t.closest('[data-fav]')) { toggleFav(fsd?.path || sel.cwd); return body(); }
|
|
1004
1414
|
if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
|
|
1005
1415
|
const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
|
|
1006
1416
|
const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
|
|
@@ -1197,6 +1607,7 @@ function onOllama(msg) {
|
|
|
1197
1607
|
|
|
1198
1608
|
/* ---------- Models per a la pissarra */
|
|
1199
1609
|
window.NioBrains = {
|
|
1610
|
+
refresh: () => brainsForBoard(true),
|
|
1200
1611
|
list() {
|
|
1201
1612
|
const out = [{ id: 'rules', label: 'Ordres ràpides' }];
|
|
1202
1613
|
if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
|
|
@@ -1218,9 +1629,12 @@ window.NioBrains = {
|
|
|
1218
1629
|
throw new Error('Aquest model ja no està disponible');
|
|
1219
1630
|
}
|
|
1220
1631
|
};
|
|
1221
|
-
|
|
1632
|
+
let brainsAt = 0;
|
|
1633
|
+
async function brainsForBoard(force) {
|
|
1222
1634
|
if (state.route.name !== 'pissarra') return;
|
|
1223
|
-
|
|
1635
|
+
window.NioBoard?.refreshBrains();
|
|
1636
|
+
if (!machine() || !state.connected || (!force && state.ollama && Date.now() - brainsAt < 30000)) return;
|
|
1637
|
+
try { const o = await api('/api/ollama'); state.ollama = o; brainsAt = Date.now(); } catch { /* sense Ollama */ }
|
|
1224
1638
|
window.NioBoard?.refreshBrains();
|
|
1225
1639
|
}
|
|
1226
1640
|
|
|
@@ -1233,7 +1647,7 @@ async function enablePush() {
|
|
|
1233
1647
|
const reg = await navigator.serviceWorker.ready;
|
|
1234
1648
|
const sub = (await reg.pushManager.getSubscription()) || (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64uToU8(state.info.vapidKey) }));
|
|
1235
1649
|
await api('/api/push', { method: 'POST', body: sub.toJSON() });
|
|
1236
|
-
syncServiceWorker(); toast('Avisos activats');
|
|
1650
|
+
syncServiceWorker(); pushOn = true; toast('Avisos activats'); if (state.route.name === 'settings' || state.route.name === 'ajustos') render();
|
|
1237
1651
|
} catch (e) { toast(e.message, 4200); }
|
|
1238
1652
|
}
|
|
1239
1653
|
async function syncServiceWorker() {
|
|
@@ -1273,7 +1687,7 @@ function viewLocal() {
|
|
|
1273
1687
|
function bindLocal() {
|
|
1274
1688
|
const feed = $('#lfeed'); if (!feed) return;
|
|
1275
1689
|
for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
|
|
1276
|
-
feed
|
|
1690
|
+
toBottom(feed);
|
|
1277
1691
|
const ta = $('#lmsg');
|
|
1278
1692
|
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1279
1693
|
ta.addEventListener('input', () => autogrow(ta));
|
|
@@ -1323,7 +1737,7 @@ async function sendLocal() {
|
|
|
1323
1737
|
const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
|
|
1324
1738
|
local.messages.push({ role: 'user', content: text });
|
|
1325
1739
|
feed.appendChild(el(`<div class="msg-user">${esc(text)}</div>`));
|
|
1326
|
-
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); feed
|
|
1740
|
+
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); toBottom(feed);
|
|
1327
1741
|
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1328
1742
|
try {
|
|
1329
1743
|
const chunks = await local.engine.chat.completions.create({
|
|
@@ -1332,7 +1746,7 @@ async function sendLocal() {
|
|
|
1332
1746
|
extra_body: { enable_thinking: false }
|
|
1333
1747
|
});
|
|
1334
1748
|
for await (const c of chunks) {
|
|
1335
|
-
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed
|
|
1749
|
+
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) toBottom(feed);
|
|
1336
1750
|
if (local.stopped) break;
|
|
1337
1751
|
}
|
|
1338
1752
|
} catch (e) {
|
|
@@ -1345,37 +1759,138 @@ async function sendLocal() {
|
|
|
1345
1759
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1346
1760
|
}
|
|
1347
1761
|
|
|
1348
|
-
/* ----------------------------------------------------------------
|
|
1349
|
-
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
if (
|
|
1356
|
-
|
|
1357
|
-
|
|
1762
|
+
/* ---------------------------------------------------------------- Pantalla: navegador i escriptori (agent-browser, agent-desktop) */
|
|
1763
|
+
let screen = null; // { caps, last, actions }
|
|
1764
|
+
const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info?.control?.escriptori?.enabled || screen?.caps?.navegador?.enabled || screen?.caps?.escriptori?.enabled);
|
|
1765
|
+
// Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
|
|
1766
|
+
function renderPanic() {
|
|
1767
|
+
syncScreenBtn();
|
|
1768
|
+
let b = $('#panicBtn');
|
|
1769
|
+
if (!controlOn() || !machine()) { b?.remove(); return; }
|
|
1770
|
+
if (!b) { b = el(`<button class="panic" id="panicBtn" type="button" data-act="panic" aria-label="Aturada d’emergència">${svg(ICON.panic)}<span>Atura-ho tot</span></button>`); document.body.appendChild(b); }
|
|
1771
|
+
}
|
|
1772
|
+
async function panicNow() {
|
|
1773
|
+
if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
|
|
1774
|
+
try { const r = await api('/api/panic', { method: 'POST' }); toast(`Aturat tot (${r.stopped} agents)`); } catch (e) { toast(e.message, 4500); }
|
|
1775
|
+
}
|
|
1776
|
+
function viewScreen() {
|
|
1777
|
+
const head = `<div class="top"><div><h1>Pantalla</h1><p>Els agents poden fer servir el navegador (agent-browser) i les apps de l’escriptori (agent-desktop). Mirar passa sol; clicar, escriure o enviar sempre et demana permís, amb una captura.</p></div><button class="btn ink" data-act="screen-now" type="button" ${controlOn() ? '' : 'disabled'}>${svg(ICON.screen)}Veure pantalla ara</button></div>`;
|
|
1778
|
+
return `${head}<div id="screenBody">${screenHtml()}</div>`;
|
|
1779
|
+
}
|
|
1780
|
+
function screenHtml() {
|
|
1781
|
+
if (!screen) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
1782
|
+
if (screen.error) return `<div class="panel empty">${bot('sleep')}<h2>${esc(screen.error)}</h2><p>Cal nio 0.7 o més nou en aquest ordinador.</p></div>`;
|
|
1783
|
+
const c = screen.caps, row = (k, name, hint) => {
|
|
1784
|
+
const x = c[k];
|
|
1785
|
+
const st = !x.installed ? `No instal·lat · <span class="mono">${hint}</span>` : x.ready === false ? esc(x.why || 'No està a punt') : x.enabled ? 'Activat' : 'Desactivat';
|
|
1786
|
+
return `<div class="agent-row"><i class="dot ${x.enabled ? 'on' : ''}"></i><span class="grow"><b>${name}</b><div class="muted small">${st}</div></span>${x.installed ? `<button class="btn sm ${x.enabled ? '' : 'ink'}" data-ctl="${k}" data-on="${!x.enabled}" type="button">${x.enabled ? 'Desactiva' : 'Activa'}</button>` : ''}</div>`;
|
|
1787
|
+
};
|
|
1788
|
+
return `<div class="grid2">
|
|
1789
|
+
<div class="stack">
|
|
1790
|
+
<section class="panel"><header><h2>Última captura</h2>${screen.last ? `<span class="muted small">${ago(screen.last.t)} · ${esc(screen.last.from)}</span>` : ''}</header><div class="body">${screen.last ? `<img class="screen-img" alt="Captura de la pantalla de l’ordinador" data-shot="${esc(screen.last.id)}">` : '<p class="muted small">Encara no n’hi ha cap. Prem «Veure pantalla ara».</p>'}</div></section>
|
|
1791
|
+
<section class="panel"><header><h2>Accions fetes</h2></header><div class="body">${screen.actions.length ? screen.actions.map((a) => `<div class="act-row"><span class="act-t mono">${new Date(a.t).toLocaleTimeString('ca', { hour: '2-digit', minute: '2-digit' })}</span><div class="grow"><div class="mono small act-what">${esc(a.what)}</div><div class="muted small">${esc(a.title || '')}</div></div></div>`).join('') : '<p class="muted small">Cap encara.</p>'}</div></section>
|
|
1792
|
+
</div>
|
|
1793
|
+
<div class="stack">
|
|
1794
|
+
<section class="panel"><header><h2>En aquest ordinador</h2></header><div class="body">${row('navegador', 'Navegador web', 'npm i -g agent-browser && agent-browser install')}${row('escriptori', 'Apps de l’escriptori', 'npm i -g agent-desktop')}<p class="muted small" style="margin-top:10px">En activar-ho, nio dona a Claude Code i a Codex les guies oficials. Desactivat per defecte. Claude Code demana permís per a cada acció; Codex no pot preguntar, així que només segueix les normes de la guia.</p></div></section>
|
|
1795
|
+
<section class="panel"><header><h2>Aturada d’emergència</h2></header><div class="body stack"><p class="small">Atura tots els agents, rebutja el que estigui pendent i tanca el navegador.</p><button class="btn danger" data-act="panic" type="button">${svg(ICON.panic)}Atura-ho tot</button></div></section>
|
|
1796
|
+
<section class="panel"><header><h2>Vols fer-ho tu?</h2></header><div class="body"><p class="muted small">nio no és un escriptori remot. Si necessites controlar la pantalla tu mateix, fes servir <a href="https://rustdesk.com" target="_blank" rel="noopener">RustDesk</a> o <a href="https://remotedesktop.google.com" target="_blank" rel="noopener">Chrome Remote Desktop</a>.</p></div></section>
|
|
1797
|
+
</div>
|
|
1798
|
+
</div>`;
|
|
1799
|
+
}
|
|
1800
|
+
async function loadScreen() {
|
|
1801
|
+
try {
|
|
1802
|
+
const [caps, cur, log] = await Promise.all([api('/api/control'), api('/api/screen'), api('/api/audit?type=tool&limit=300')]);
|
|
1803
|
+
screen = { caps, last: cur.last, actions: log.filter((e) => /^(npx\s+)?agent-(browser|desktop)\b/.test(e.what || '')).slice(0, 30) };
|
|
1804
|
+
} catch (e) { screen = { error: e.message }; }
|
|
1805
|
+
const box = $('#screenBody'); if (box) { box.innerHTML = screenHtml(); hydrateShots(box); }
|
|
1806
|
+
renderPanic();
|
|
1807
|
+
}
|
|
1808
|
+
function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
|
|
1809
|
+
function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; if (state.route.name === 'pantalla') { const box = $('#screenBody'); box.innerHTML = screenHtml(); hydrateShots(box); } } }
|
|
1810
|
+
document.addEventListener('click', async (e) => {
|
|
1811
|
+
const b = e.target.closest('[data-ctl]'); if (!b) return;
|
|
1812
|
+
b.disabled = true;
|
|
1813
|
+
try { const r = await api('/api/control', { method: 'PUT', body: { [b.dataset.ctl]: b.dataset.on === 'true' } }); screen.caps = r; if (state.info) state.info.control = r; toast(b.dataset.on === 'true' ? 'Activat. Les guies ja són a Claude Code i Codex.' : 'Desactivat'); (r.notes || []).forEach((n) => toast(n, 5000)); }
|
|
1814
|
+
catch (err) { toast(err.message, 6000); }
|
|
1815
|
+
const box = $('#screenBody'); if (box) { box.innerHTML = screenHtml(); hydrateShots(box); } renderPanic();
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
/* ---------------------------------------------------------------- Telegram (bot propi, configurat a l'ordinador) */
|
|
1819
|
+
let tgState;
|
|
1820
|
+
async function loadTelegram() {
|
|
1821
|
+
try { tgState = await api('/api/telegram'); } catch { tgState = null; }
|
|
1822
|
+
const box = $('#tgPanel'); if (box) box.innerHTML = telegramPanel();
|
|
1823
|
+
}
|
|
1824
|
+
function telegramPanel() {
|
|
1825
|
+
if (!machine()) return '';
|
|
1826
|
+
const t = tgState;
|
|
1827
|
+
if (t === undefined) return '<section class="panel"><header><h2>Telegram</h2></header><div class="body"><p class="muted small">Carregant…</p></div></section>';
|
|
1828
|
+
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>';
|
|
1829
|
+
if (!t.configured) return `<section class="panel"><header><h2>Telegram</h2></header><div class="body stack">
|
|
1830
|
+
<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>
|
|
1831
|
+
<ol class="muted small" style="padding-left:1.2em"><li>A Telegram, obre <b>@BotFather</b> i envia <span class="mono">/newbot</span>.</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>
|
|
1832
|
+
<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>`;
|
|
1833
|
+
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">
|
|
1834
|
+
${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>` : ''}
|
|
1835
|
+
<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>
|
|
1836
|
+
<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>
|
|
1837
|
+
<label class="f"><span>Permisos</span><select id="tgMode">${Object.entries(MODES).map(([k, m]) => `<option value="${k}" ${t.mode === k ? 'selected' : ''}>${m.label}</option>`).join('')}</select></label></div>
|
|
1838
|
+
<label class="row check"><input type="checkbox" id="tgNotify" ${t.notify ? 'checked' : ''}> <span>Envia-hi els avisos (aprovacions, feines acabades i errors)</span></label>
|
|
1839
|
+
${t.voice ? '' : '<p class="muted small">Per entendre notes de veu, configura la veu (a dalt).</p>'}
|
|
1840
|
+
<div class="row"><button class="btn sm danger" data-tg-off type="button">Desconnecta el bot</button></div></div></section>`;
|
|
1841
|
+
}
|
|
1842
|
+
document.addEventListener('submit', async (e) => {
|
|
1843
|
+
if (e.target.id !== 'tgForm') return; e.preventDefault();
|
|
1844
|
+
try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message, 5000); }
|
|
1845
|
+
$('#tgPanel').innerHTML = telegramPanel();
|
|
1846
|
+
});
|
|
1847
|
+
document.addEventListener('change', async (e) => {
|
|
1848
|
+
const k = { tgAgent: 'agent', tgMode: 'mode', tgNotify: 'notify' }[e.target.id]; if (!k) return;
|
|
1849
|
+
try { tgState = await api('/api/telegram', { method: 'PUT', body: { [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value } }); toast('Desat'); } catch (err) { toast(err.message); }
|
|
1850
|
+
});
|
|
1851
|
+
document.addEventListener('click', async (e) => {
|
|
1852
|
+
const b = e.target.closest('[data-tg-allow], [data-tg-drop], [data-tg-off]'); if (!b) return;
|
|
1853
|
+
try {
|
|
1854
|
+
if (b.dataset.tgAllow) tgState = await api('/api/telegram/allow', { method: 'POST', body: { id: Number(b.dataset.tgAllow), name: b.dataset.name } });
|
|
1855
|
+
if (b.dataset.tgDrop) tgState = await api(`/api/telegram/allow/${b.dataset.tgDrop}`, { method: 'DELETE' });
|
|
1856
|
+
if (b.hasAttribute('data-tg-off')) { if (!confirm('El bot deixarà de funcionar amb aquest ordinador.')) return; tgState = await api('/api/telegram', { method: 'PUT', body: { token: null } }); }
|
|
1857
|
+
} catch (err) { return toast(err.message); }
|
|
1858
|
+
$('#tgPanel').innerHTML = telegramPanel();
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
/* ---------------------------------------------------------------- veu (a l'ordinador) */
|
|
1862
|
+
// Les claus (ElevenLabs o OpenAI) viuen a l'ordinador. L'àudio fa: mòbil → ordinador → proveïdor.
|
|
1863
|
+
let nodeVoice = null; // estat de la veu de l'ordinador actiu (sense claus)
|
|
1864
|
+
const voiceOn = () => !!nodeVoice?.stt;
|
|
1865
|
+
async function loadVoice() { try { nodeVoice = await api('/api/voice'); } catch { nodeVoice = null; } }
|
|
1866
|
+
async function nodeAudio(path, opts) {
|
|
1867
|
+
const m = machine(); if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
1868
|
+
const res = await fetch(m.url + path, { method: 'POST', ...opts, headers: { Authorization: `Bearer ${m.token}`, ...(opts.headers || {}) } });
|
|
1869
|
+
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `Error ${res.status}`); }
|
|
1870
|
+
return res;
|
|
1358
1871
|
}
|
|
1359
1872
|
const speech = { audio: null, btn: null, cache: new Map() };
|
|
1360
|
-
const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0,
|
|
1873
|
+
const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0, 2500);
|
|
1361
1874
|
function stopSpeech() { speech.audio?.pause(); speech.audio = null; speech.btn?.classList.remove('on', 'wait'); speech.btn = null; }
|
|
1362
|
-
async function
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq)), text = speechText(ev?.text || '');
|
|
1366
|
-
if (!text) return;
|
|
1367
|
-
speech.btn = btn; btn.classList.add('wait');
|
|
1875
|
+
async function playText(text, btn) {
|
|
1876
|
+
text = speechText(text); if (!text) return;
|
|
1877
|
+
if (btn) { speech.btn = btn; btn.classList.add('wait'); }
|
|
1368
1878
|
try {
|
|
1369
|
-
|
|
1370
|
-
if (!url) {
|
|
1371
|
-
|
|
1372
|
-
url = URL.createObjectURL(await res.blob()); speech.cache.set(k, url);
|
|
1373
|
-
}
|
|
1374
|
-
if (speech.btn !== btn) return;
|
|
1879
|
+
let url = speech.cache.get(text);
|
|
1880
|
+
if (!url) { url = URL.createObjectURL(await (await nodeAudio('/api/voice/tts', { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) })).blob()); speech.cache.set(text, url); }
|
|
1881
|
+
if (btn && speech.btn !== btn) return;
|
|
1375
1882
|
const a = new Audio(url); speech.audio = a; a.onended = stopSpeech; await a.play();
|
|
1376
|
-
btn
|
|
1883
|
+
btn?.classList.remove('wait'); btn?.classList.add('on');
|
|
1377
1884
|
} catch (e) { stopSpeech(); toast(e.message, 4500); }
|
|
1378
1885
|
}
|
|
1886
|
+
async function speak(btn) {
|
|
1887
|
+
if (speech.btn === btn) return stopSpeech();
|
|
1888
|
+
stopSpeech();
|
|
1889
|
+
const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq));
|
|
1890
|
+
return playText(ev?.text || '', btn);
|
|
1891
|
+
}
|
|
1892
|
+
// Lectura automàtica (si l'has activada): quan l'agent acaba una resposta a la sessió oberta.
|
|
1893
|
+
function autoSpeak(ev) { if (nodeVoice?.speak && nodeVoice?.tts && !document.hidden && ev?.text) { stopSpeech(); playText(ev.text); } }
|
|
1379
1894
|
const mic = { rec: null, chunks: [], stream: null };
|
|
1380
1895
|
async function toggleMic() {
|
|
1381
1896
|
if (!voiceOn()) return voiceSheet();
|
|
@@ -1390,8 +1905,7 @@ async function toggleMic() {
|
|
|
1390
1905
|
$('#micBtn')?.classList.remove('rec'); $('#micBtn')?.classList.add('wait');
|
|
1391
1906
|
try {
|
|
1392
1907
|
if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
|
|
1393
|
-
const
|
|
1394
|
-
const r = await (await elevenlabs('/v1/speech-to-text', { method: 'POST', body: fd })).json(), ta = $('#msg');
|
|
1908
|
+
const r = await (await nodeAudio('/api/voice/stt', { headers: { 'content-type': blob.type || 'audio/webm' }, body: blob })).json(), ta = $('#msg');
|
|
1395
1909
|
if (!r.text?.trim()) toast('No he entès res.');
|
|
1396
1910
|
else if (ta) { ta.value = `${ta.value ? `${ta.value} ` : ''}${r.text.trim()}`; ta.dispatchEvent(new Event('input')); ta.focus(); }
|
|
1397
1911
|
} catch (e) { toast(e.message, 4500); }
|
|
@@ -1400,26 +1914,49 @@ async function toggleMic() {
|
|
|
1400
1914
|
rec.start(); $('#micBtn')?.classList.add('rec');
|
|
1401
1915
|
}
|
|
1402
1916
|
function voicePanel() {
|
|
1403
|
-
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1917
|
+
if (!machine()) return '';
|
|
1918
|
+
const v = nodeVoice, P = v?.providers || {};
|
|
1919
|
+
const on = Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name);
|
|
1920
|
+
return `<section class="panel"><header><h2>Veu</h2>${on.length ? `<span class="chip ok"><i class="dot on"></i>${esc(on.join(' · '))}</span>` : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes. Les claus es queden a l’ordinador (${esc(machine().name)}); l’àudio va del mòbil a l’ordinador i d’allà al proveïdor.</p>${v ? '' : '<p class="muted small">Aquest ordinador encara no té la veu (cal nio 0.7 o més nou).</p>'}<div class="row"><button class="btn ink" data-act="voice" type="button" ${v ? '' : 'disabled'}>${on.length ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
|
|
1921
|
+
}
|
|
1922
|
+
async function voiceSheet() {
|
|
1923
|
+
await loadVoice();
|
|
1924
|
+
if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
|
|
1925
|
+
const v = nodeVoice, old = LS.get('nio.voice', {});
|
|
1926
|
+
openSheet(`<h2>Veu</h2><p class="muted small">Les claus es desen a l’ordinador «${esc(machine().name)}» i no tornen mai al mòbil. Amb una sola clau n’hi ha prou.</p>
|
|
1408
1927
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
1409
|
-
<label class="f"><span>Clau d’
|
|
1410
|
-
<label class="f"><span>
|
|
1411
|
-
<label class="f"><span>
|
|
1412
|
-
<
|
|
1928
|
+
<label class="f"><span>Clau d’ElevenLabs ${v.providers.elevenlabs.key ? `<span class="muted">(desada: ${esc(v.providers.elevenlabs.key)})</span>` : ''}</span><input type="password" id="vEl" placeholder="${v.providers.elevenlabs.key ? 'Deixa-ho buit per mantenir-la' : 'sk_…'}" value="${!v.providers.elevenlabs.key && old.key ? esc(old.key) : ''}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1929
|
+
<label class="f"><span>Clau d’OpenAI ${v.providers.openai.key ? `<span class="muted">(desada: ${esc(v.providers.openai.key)})</span>` : ''}</span><input type="password" id="vOa" placeholder="${v.providers.openai.key ? 'Deixa-ho buit per mantenir-la' : 'sk-…'}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1930
|
+
<div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option><option value="elevenlabs" ${v.stt === 'elevenlabs' ? 'selected' : ''}>ElevenLabs Scribe</option><option value="openai" ${v.stt === 'openai' ? 'selected' : ''}>OpenAI</option></select></label>
|
|
1931
|
+
<label class="f"><span>Per parlar</span><select id="vTts"><option value="">Automàtic</option><option value="elevenlabs" ${v.tts === 'elevenlabs' ? 'selected' : ''}>ElevenLabs</option><option value="openai" ${v.tts === 'openai' ? 'selected' : ''}>OpenAI</option></select></label></div>
|
|
1932
|
+
<label class="row check"><input type="checkbox" id="vSpeak" ${v.speak ? 'checked' : ''}> <span>Llegeix en veu alta les respostes de la sessió oberta</span></label>
|
|
1933
|
+
${old.key ? '<p class="muted small">Tenies una clau d’ElevenLabs desada en aquest navegador: la passo a l’ordinador i l’esborro d’aquí.</p>' : ''}
|
|
1934
|
+
<div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff">Treu les claus</button><button class="btn ink" type="submit">Desa</button></div>
|
|
1413
1935
|
</form>`, (sh) => {
|
|
1414
|
-
const
|
|
1415
|
-
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const
|
|
1416
|
-
$('#vOff', sh).onclick = () => {
|
|
1936
|
+
const put = async (body, msg) => { try { nodeVoice = await api('/api/voice', { method: 'PUT', body }); try { localStorage.removeItem('nio.voice'); } catch { /* res */ } closeSheet(); toast(msg); if (state.route.name === 'session') renderSession(); else render(); } catch (e) { toast(e.message, 4500); } };
|
|
1937
|
+
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const keys = {}; const el = $('#vEl', sh).value.trim(), oa = $('#vOa', sh).value.trim(); if (el) keys.elevenlabs = el; if (oa) keys.openai = oa; put({ keys, stt: $('#vStt', sh).value || null, tts: $('#vTts', sh).value || null, speak: $('#vSpeak', sh).checked }, 'Veu desada a l’ordinador'); };
|
|
1938
|
+
$('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
|
|
1417
1939
|
});
|
|
1418
1940
|
}
|
|
1419
1941
|
|
|
1420
1942
|
/* ---------------------------------------------------------------- accions */
|
|
1943
|
+
document.addEventListener('click', (e) => {
|
|
1944
|
+
const f = e.target.closest('[data-filter]'); if (!f) return;
|
|
1945
|
+
actFilter = f.dataset.filter; $$('.act-filter button').forEach((b) => b.setAttribute('aria-pressed', b.dataset.filter === actFilter));
|
|
1946
|
+
const box = $('#actList'); if (box) box.innerHTML = activityHtml();
|
|
1947
|
+
});
|
|
1948
|
+
// Les captures de les aprovacions es demanen amb la clau: no poden anar en un <img src> directe.
|
|
1949
|
+
async function hydrateShots(root = document) {
|
|
1950
|
+
const m = machine(); if (!m) return;
|
|
1951
|
+
for (const img of $$('img[data-shot]:not([src])', root)) {
|
|
1952
|
+
try {
|
|
1953
|
+
const res = await fetch(`${m.url}/api/screen/shot/${encodeURIComponent(img.dataset.shot)}`, { headers: { Authorization: `Bearer ${m.token}` } });
|
|
1954
|
+
if (!res.ok) throw 0; img.src = URL.createObjectURL(await res.blob());
|
|
1955
|
+
} catch { img.remove(); }
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1421
1958
|
document.addEventListener('click', async (e) => {
|
|
1422
|
-
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm]');
|
|
1959
|
+
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm], [data-man], [data-pick]');
|
|
1423
1960
|
if (!a) return;
|
|
1424
1961
|
if (a.dataset.copy) {
|
|
1425
1962
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1432,6 +1969,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1432
1969
|
}
|
|
1433
1970
|
if (a.dataset.speak != null) return speak(a);
|
|
1434
1971
|
if (a.dataset.mic != null) return toggleMic();
|
|
1972
|
+
if (a.dataset.man) return manageSelect(a.dataset.man);
|
|
1973
|
+
if (a.dataset.pick) { manage.has(a.dataset.pick) ? manage.delete(a.dataset.pick) : manage.add(a.dataset.pick); return render(); }
|
|
1435
1974
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1436
1975
|
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1437
1976
|
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; reconTry.delete(m.via); if (sshHelper()) { autoReconnect({ ...m }); return; } return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
@@ -1440,6 +1979,9 @@ document.addEventListener('click', async (e) => {
|
|
|
1440
1979
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1441
1980
|
switch (a.dataset.act) {
|
|
1442
1981
|
case 'new': return newSessionSheet();
|
|
1982
|
+
case 'manage-on': manage = new Set(); return render();
|
|
1983
|
+
case 'manage-off': manage = null; return render();
|
|
1984
|
+
case 'manage-del': return manageDelete();
|
|
1443
1985
|
case 'ollama-refresh': state.ollama = null; return loadOllama(true);
|
|
1444
1986
|
case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
|
|
1445
1987
|
case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
|
|
@@ -1451,6 +1993,14 @@ document.addEventListener('click', async (e) => {
|
|
|
1451
1993
|
case 'session-more': return sessionOptions(state.route.id);
|
|
1452
1994
|
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
1453
1995
|
case 'push': return enablePush();
|
|
1996
|
+
case 'push-off': return disablePush();
|
|
1997
|
+
case 'revoke': return revokeDevice(a.dataset.dev);
|
|
1998
|
+
case 'job-new': return newJobSheet();
|
|
1999
|
+
case 'panic': return panicNow();
|
|
2000
|
+
case 'screen-now': try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); } catch (err) { toast(err.message, 4500); } return;
|
|
2001
|
+
case 'mem-new': return memEdit(null);
|
|
2002
|
+
case 'mem-all': if (mem) delete mem.hits; return loadMemory();
|
|
2003
|
+
case 'activity-refresh': activity = null; return loadActivity();
|
|
1454
2004
|
case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
|
|
1455
2005
|
case 'add-computer': return addComputerSheet();
|
|
1456
2006
|
case 'open-sessions': return go('#/sessions');
|
|
@@ -1476,7 +2026,7 @@ document.addEventListener('submit', (e) => {
|
|
|
1476
2026
|
e.preventDefault();
|
|
1477
2027
|
const pair = pairFrom($('#pairInput', e.target).value);
|
|
1478
2028
|
if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
|
|
1479
|
-
closeSheet();
|
|
2029
|
+
closeSheet(); toast('Connectant…'); pairAndAdd(pair); go('#/sessions');
|
|
1480
2030
|
});
|
|
1481
2031
|
|
|
1482
2032
|
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
@@ -1636,7 +2186,8 @@ $('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false),
|
|
|
1636
2186
|
// Al mòbil, l'alçada visible real (amb el teclat obert o tancat), perquè el camp de text quedi sempre just a sota.
|
|
1637
2187
|
if (window.visualViewport) {
|
|
1638
2188
|
const vv = visualViewport, root = document.documentElement;
|
|
1639
|
-
|
|
2189
|
+
// A l'iPhone el teclat no encongeix la pàgina: el camp d'escriure puja el que ocupi el teclat (--kb).
|
|
2190
|
+
const fit = () => { root.style.setProperty('--vvh', `${vv.height}px`); root.style.setProperty('--vvt', `${vv.offsetTop}px`); root.style.setProperty('--kb', `${Math.round(Math.max(0, innerHeight - vv.height - vv.offsetTop))}px`); document.body.classList.toggle('kb', innerHeight - vv.height > 120); };
|
|
1640
2191
|
vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); fit();
|
|
1641
2192
|
}
|
|
1642
2193
|
if ('serviceWorker' in navigator) {
|
|
@@ -1660,3 +2211,59 @@ if (!checkPairHash()) state.route = parseRoute();
|
|
|
1660
2211
|
if (machine()) connect();
|
|
1661
2212
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
1662
2213
|
if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
|
|
2214
|
+
|
|
2215
|
+
/* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
|
|
2216
|
+
(() => {
|
|
2217
|
+
const PAGES = ['sessions', 'approvals', 'ordinadors'];
|
|
2218
|
+
const view = $('#view');
|
|
2219
|
+
let g = null, timer = 0;
|
|
2220
|
+
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; };
|
|
2221
|
+
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);
|
|
2222
|
+
const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
|
|
2223
|
+
const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
|
|
2224
|
+
const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
|
|
2225
|
+
addEventListener('touchstart', (e) => {
|
|
2226
|
+
g = null;
|
|
2227
|
+
if (e.touches.length !== 1 || !view.contains(e.target) || !PAGES.includes(state.route.name) || blocked(e.target)) return;
|
|
2228
|
+
const t = e.touches[0];
|
|
2229
|
+
if (t.clientX < 24 || t.clientX > innerWidth - 24) return;
|
|
2230
|
+
clearTimeout(timer); clear();
|
|
2231
|
+
g = { x: t.clientX, y: t.clientY, t: e.timeStamp, dx: 0, lock: null };
|
|
2232
|
+
}, { passive: true });
|
|
2233
|
+
addEventListener('touchmove', (e) => {
|
|
2234
|
+
if (!g) return;
|
|
2235
|
+
const t = e.touches[0], dx = t.clientX - g.x, dy = t.clientY - g.y;
|
|
2236
|
+
if (!g.lock) {
|
|
2237
|
+
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
|
|
2238
|
+
g.lock = Math.abs(dx) > Math.abs(dy) * 1.3 ? 'x' : 'y';
|
|
2239
|
+
if (g.lock === 'x') view.classList.add('swiping');
|
|
2240
|
+
}
|
|
2241
|
+
if (g.lock !== 'x') { g = null; return; }
|
|
2242
|
+
if (e.cancelable) e.preventDefault();
|
|
2243
|
+
g.dx = dx;
|
|
2244
|
+
const ok = target(dx < 0 ? 1 : -1);
|
|
2245
|
+
put(dx * (ok ? 0.8 : 0.2), 1 - Math.min(Math.abs(dx) / innerWidth, 0.6) * (ok ? 0.7 : 0.2));
|
|
2246
|
+
}, { passive: false });
|
|
2247
|
+
const end = (e) => {
|
|
2248
|
+
if (!g || g.lock !== 'x') { g = null; return; }
|
|
2249
|
+
const { dx } = g, v = Math.abs(dx) / Math.max(e.timeStamp - g.t, 1), dir = dx < 0 ? 1 : -1, to = target(dir);
|
|
2250
|
+
g = null;
|
|
2251
|
+
const far = Math.abs(dx) > 56 || (Math.abs(dx) > 20 && v > 0.35);
|
|
2252
|
+
if (!to || !far) {
|
|
2253
|
+
view.classList.add('swipe-anim'); put(0, 1);
|
|
2254
|
+
timer = setTimeout(clear, 200);
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
navigator.vibrate?.(6);
|
|
2258
|
+
view.classList.remove('swipe-anim');
|
|
2259
|
+
addEventListener('hashchange', () => {
|
|
2260
|
+
if (reduce) { clear(); return; }
|
|
2261
|
+
put(dir * innerWidth * 0.35, 0.2); view.offsetWidth;
|
|
2262
|
+
view.classList.add('swipe-anim'); put(0, 1);
|
|
2263
|
+
timer = setTimeout(clear, 200);
|
|
2264
|
+
}, { once: true });
|
|
2265
|
+
go(HASH[to]);
|
|
2266
|
+
};
|
|
2267
|
+
addEventListener('touchend', end, { passive: true });
|
|
2268
|
+
addEventListener('touchcancel', () => { if (g?.lock === 'x') { view.classList.add('swipe-anim'); put(0, 1); timer = setTimeout(clear, 200); } g = null; }, { passive: true });
|
|
2269
|
+
})();
|