nio-cat 0.5.0 → 0.6.1
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/bin/nio.js +150 -13
- package/package.json +2 -1
- package/src/agents/claude.js +3 -1
- package/src/audit.js +55 -0
- package/src/auth.js +175 -0
- package/src/commands.js +12 -1
- package/src/directory.js +56 -0
- package/src/hub.js +60 -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 +93 -31
- package/src/service.js +123 -0
- package/src/tunnel.js +150 -34
- package/web/app/app.js +558 -44
- package/web/app/style.css +73 -11
- package/web/board.css +3 -2
- package/web/board.js +4 -3
- package/web/sw.js +13 -9
package/web/app/app.js
CHANGED
|
@@ -12,8 +12,17 @@ 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
|
+
memory: '<path d="M6 4h9l3 3v13H6z"/><path d="M9 11h6M9 15h6M14 4v4h4"/>',
|
|
17
26
|
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
18
27
|
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
19
28
|
phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
|
|
@@ -46,7 +55,7 @@ const MODES = {
|
|
|
46
55
|
};
|
|
47
56
|
|
|
48
57
|
/* ---------------------------------------------------------------- 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() };
|
|
58
|
+
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
59
|
const LS = {
|
|
51
60
|
get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
|
52
61
|
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
|
|
@@ -137,9 +146,81 @@ async function api(path, opts = {}) {
|
|
|
137
146
|
return data;
|
|
138
147
|
}
|
|
139
148
|
|
|
140
|
-
let streamCtrl = null, retry = 0;
|
|
149
|
+
let streamCtrl = null, retry = 0, gotHello = false;
|
|
150
|
+
async function pollLoop(ctrl) {
|
|
151
|
+
let first = true;
|
|
152
|
+
while (!ctrl.signal.aborted) {
|
|
153
|
+
try {
|
|
154
|
+
const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
|
|
155
|
+
if (ctrl.signal.aborted) return;
|
|
156
|
+
state.connected = true; state.lastError = null;
|
|
157
|
+
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(); }
|
|
158
|
+
else {
|
|
159
|
+
const r = state.route, seen = new Set();
|
|
160
|
+
for (const x of list) {
|
|
161
|
+
seen.add(x.id); const old = state.sessions.get(x.id);
|
|
162
|
+
if (!old || old.updated !== x.updated || old.state !== x.state || old.pending !== x.pending || old.title !== x.title) handle({ type: 'session', session: x });
|
|
163
|
+
}
|
|
164
|
+
for (const id of [...state.sessions.keys()]) if (!seen.has(id)) handle({ type: 'removed', session: id });
|
|
165
|
+
for (const a of aps) if (!state.approvals.has(a.id)) handle({ type: 'approval', approval: a });
|
|
166
|
+
for (const id of [...state.approvals.keys()]) if (!aps.some((a) => a.id === id)) { state.approvals.delete(id); renderChrome(); if (r.name === 'approvals') render(); }
|
|
167
|
+
const evs = r.name === 'session' && state.events.get(r.id);
|
|
168
|
+
if (evs && state.sessions.has(r.id)) {
|
|
169
|
+
const since = Math.max(0, ...evs.keys());
|
|
170
|
+
const d = await api(`/api/sessions/${r.id}?since=${since}`);
|
|
171
|
+
for (const e of d.events) if (!evs.has(e.seq)) handle({ type: 'event', session: r.id, event: e });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (ctrl.signal.aborted) return;
|
|
176
|
+
state.connected = false; state.lastError = 'No arribo a l’ordinador'; render();
|
|
177
|
+
return setTimeout(() => machine() && !ctrl.signal.aborted && connect(), 3000);
|
|
178
|
+
}
|
|
179
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/* ---------- Adreça recordada a nio.cat
|
|
183
|
+
Cada ordinador publica la seva adreça actual a nio.cat, signada amb una clau Ed25519 pròpia.
|
|
184
|
+
Si el túnel canvia d'adreça i no hi arribem, la busquem allà i només la fem servir si la
|
|
185
|
+
signatura és de la clau que vam guardar en aparellar-nos (nio.cat no pot enganyar-nos). */
|
|
186
|
+
const DIRECTORY = 'https://www.nio.cat';
|
|
187
|
+
function rememberIdentity(url, ident) {
|
|
188
|
+
if (!ident?.pub || !ident?.loc) return;
|
|
189
|
+
const ms = store.machines, m = ms.find((x) => x.url === url);
|
|
190
|
+
if (!m || m.pub === ident.pub) return;
|
|
191
|
+
if (m.pub) return; // una altra clau a la mateixa adreça: no la canviem mai sola
|
|
192
|
+
m.pub = ident.pub; m.loc = ident.loc; store.machines = ms;
|
|
193
|
+
}
|
|
194
|
+
const b64uBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4)), (c) => c.charCodeAt(0));
|
|
195
|
+
async function signedBy(rec, m) {
|
|
196
|
+
if (rec.pub !== m.pub) return false;
|
|
197
|
+
try {
|
|
198
|
+
const key = await crypto.subtle.importKey('raw', b64uBytes(m.pub), { name: 'Ed25519' }, false, ['verify']);
|
|
199
|
+
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}`));
|
|
200
|
+
} catch {
|
|
201
|
+
// Navegador sense Ed25519 (iOS < 17, Chrome < 113): ens refiem de la comprovació que fa nio.cat.
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const relocating = new Map();
|
|
206
|
+
async function relocate(i) {
|
|
207
|
+
const ms = store.machines, m = ms[i];
|
|
208
|
+
if (!m?.loc || Date.now() - (relocating.get(m.loc) || 0) < 15000) return false;
|
|
209
|
+
relocating.set(m.loc, Date.now());
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(`${DIRECTORY}/api/node?id=${encodeURIComponent(m.loc)}`, { cache: 'no-store', signal: AbortSignal.timeout(6000) });
|
|
212
|
+
if (!res.ok) return false;
|
|
213
|
+
const rec = await res.json();
|
|
214
|
+
if (!rec.url || rec.url === m.url || (m.locT && rec.t <= m.locT) || !/^https:\/\//.test(rec.url) || !(await signedBy(rec, m))) return false;
|
|
215
|
+
const fresh = store.machines, x = fresh.find((y) => y.loc === m.loc); if (!x) return false;
|
|
216
|
+
x.url = rec.url; x.locT = rec.t; store.machines = fresh;
|
|
217
|
+
if (i === store.active) toast('L’ordinador ha canviat d’adreça. Ja hi torno a ser.');
|
|
218
|
+
return true;
|
|
219
|
+
} catch { return false; }
|
|
220
|
+
}
|
|
221
|
+
|
|
141
222
|
async function connect() {
|
|
142
|
-
streamCtrl?.abort();
|
|
223
|
+
streamCtrl?.abort(); state.synced = false;
|
|
143
224
|
const m = machine();
|
|
144
225
|
if (!m) { state.connected = false; return render(); }
|
|
145
226
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
@@ -149,10 +230,14 @@ async function connect() {
|
|
|
149
230
|
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
231
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
151
232
|
state.connected = true; state.lastError = null; retry = 0;
|
|
233
|
+
rememberIdentity(m.url, state.info.identity);
|
|
152
234
|
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard();
|
|
153
235
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
236
|
+
// 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.
|
|
237
|
+
gotHello = false; let poll = false;
|
|
238
|
+
const hw = setTimeout(() => { if (!gotHello) { poll = true; reader.cancel().catch(() => {}); } }, 5000);
|
|
154
239
|
for (;;) {
|
|
155
|
-
const { value, done } = await reader.read(); if (done) break;
|
|
240
|
+
const { value, done } = await reader.read().catch(() => ({ done: true })); if (done) break;
|
|
156
241
|
buf += dec.decode(value, { stream: true }); let i;
|
|
157
242
|
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
158
243
|
const block = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
@@ -160,6 +245,8 @@ async function connect() {
|
|
|
160
245
|
if (data) handle(JSON.parse(data));
|
|
161
246
|
}
|
|
162
247
|
}
|
|
248
|
+
clearTimeout(hw);
|
|
249
|
+
if (poll) return pollLoop(ctrl);
|
|
163
250
|
throw new Error('S’ha tallat la connexió');
|
|
164
251
|
} catch (e) {
|
|
165
252
|
if (ctrl.signal.aborted) return;
|
|
@@ -167,6 +254,8 @@ async function connect() {
|
|
|
167
254
|
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
255
|
render();
|
|
169
256
|
if (e.fatal) return;
|
|
257
|
+
// Potser el túnel ha canviat d'adreça: la busquem a nio.cat (signada per l'ordinador).
|
|
258
|
+
if (state.lastError === 'No arribo a l’ordinador' && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
|
|
170
259
|
if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
|
|
171
260
|
setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
|
|
172
261
|
}
|
|
@@ -213,6 +302,7 @@ function handle(msg) {
|
|
|
213
302
|
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
214
303
|
break;
|
|
215
304
|
case 'hello':
|
|
305
|
+
gotHello = true; state.synced = true;
|
|
216
306
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
217
307
|
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
218
308
|
state.loaded.clear(); state.events.clear();
|
|
@@ -234,6 +324,7 @@ function handle(msg) {
|
|
|
234
324
|
break;
|
|
235
325
|
}
|
|
236
326
|
case 'ollama': onOllama(msg); break;
|
|
327
|
+
case 'job': onJob(msg); break;
|
|
237
328
|
case 'approval':
|
|
238
329
|
if (msg.approval.state === 'pending') { state.approvals.set(msg.approval.id, msg.approval); if (!document.hidden && navigator.vibrate) navigator.vibrate(30); }
|
|
239
330
|
else if (state.approvals.has(msg.approval.id)) { markDecided(msg.approval.id, msg.approval.state); break; }
|
|
@@ -281,6 +372,30 @@ function addMachine({ url, token, name, kind, via }) {
|
|
|
281
372
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
282
373
|
connect();
|
|
283
374
|
}
|
|
375
|
+
// El codi o l'enllaç porta un secret d'un sol ús: el canviem per una clau pròpia d'aquest dispositiu,
|
|
376
|
+
// que es pot revocar des d'Ordinadors. Si és la clau principal (el panell que obre nio) o un nio antic
|
|
377
|
+
// que encara no fa aparellament, el valor ja és una clau i el fem servir tal qual.
|
|
378
|
+
function deviceName() {
|
|
379
|
+
const ua = navigator.userAgent;
|
|
380
|
+
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';
|
|
381
|
+
const br = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : '';
|
|
382
|
+
return `${dev}${br ? ` · ${br}` : ''}${standalone ? ' (app)' : ''}`.slice(0, 60);
|
|
383
|
+
}
|
|
384
|
+
async function claimPair(pair) {
|
|
385
|
+
let res = null;
|
|
386
|
+
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' }); }
|
|
387
|
+
catch { return pair; } // sense connexió ara: ho resoldrà la connexió
|
|
388
|
+
if (res.ok) { const d = await res.json(); return { ...pair, token: d.token, name: pair.name || d.host, identity: d.identity }; }
|
|
389
|
+
const why = (await res.json().catch(() => ({}))).error;
|
|
390
|
+
const ok = await fetch(`${pair.url}/api/info`, { headers: { Authorization: `Bearer ${pair.token}` }, cache: 'no-store' }).then((r) => r.ok).catch(() => false);
|
|
391
|
+
if (ok) return pair;
|
|
392
|
+
throw new Error(why || 'Aquest codi no és vàlid o ha caducat. Demana’n un de nou amb «nio pair».');
|
|
393
|
+
}
|
|
394
|
+
async function pairAndAdd(pair, { msg = 'Ordinador connectat', after } = {}) {
|
|
395
|
+
try { const p = await claimPair(pair); addMachine(p); rememberIdentity(p.url, p.identity); toast(msg); after?.(); }
|
|
396
|
+
catch (e) { toast(e.message, 6000); }
|
|
397
|
+
render();
|
|
398
|
+
}
|
|
284
399
|
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
285
400
|
function switchMachine(i) {
|
|
286
401
|
closeSheet(); store.active = i; streamCtrl?.abort();
|
|
@@ -300,9 +415,9 @@ function checkPairHash() {
|
|
|
300
415
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
301
416
|
state.route = { name: 'sessions' };
|
|
302
417
|
const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
|
|
303
|
-
|
|
418
|
+
render();
|
|
304
419
|
// 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);
|
|
420
|
+
pairAndAdd(pair, { after: () => { if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500); } });
|
|
306
421
|
return true;
|
|
307
422
|
}
|
|
308
423
|
|
|
@@ -310,6 +425,9 @@ function checkPairHash() {
|
|
|
310
425
|
const VIEWS = {
|
|
311
426
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
312
427
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
428
|
+
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
429
|
+
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Feina' },
|
|
430
|
+
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
|
|
313
431
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
314
432
|
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
315
433
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
@@ -317,8 +435,8 @@ const VIEWS = {
|
|
|
317
435
|
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
318
436
|
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
319
437
|
};
|
|
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']);
|
|
438
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
439
|
+
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', 'models', 'session']);
|
|
322
440
|
function parseRoute() {
|
|
323
441
|
const h = location.hash.replace(/^#\/?/, '');
|
|
324
442
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
@@ -363,6 +481,7 @@ function renderChrome() {
|
|
|
363
481
|
}
|
|
364
482
|
function render() {
|
|
365
483
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
484
|
+
if (r.name !== 'sessions') manage = null;
|
|
366
485
|
renderChrome();
|
|
367
486
|
document.body.classList.toggle('board-mode', board);
|
|
368
487
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
|
|
@@ -375,22 +494,43 @@ function render() {
|
|
|
375
494
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
376
495
|
else {
|
|
377
496
|
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>`;
|
|
497
|
+
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 === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
|
|
379
498
|
if (r.name === 'mobil') bindLocal();
|
|
380
499
|
if (r.name === 'models' && machine()) loadOllama();
|
|
500
|
+
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
501
|
+
if (r.name === 'memoria' && machine()) loadMemory();
|
|
502
|
+
if (r.name === 'feines' && machine()) loadJobs();
|
|
503
|
+
hydrateShots();
|
|
381
504
|
}
|
|
382
505
|
if (changed && !board) { view.classList.remove('view-enter'); void view.offsetWidth; view.classList.add('view-enter'); }
|
|
383
506
|
window.NioBot?.refresh();
|
|
384
507
|
tourUpdate();
|
|
508
|
+
if (!board) setTimeout(focusMain, 60);
|
|
385
509
|
}
|
|
386
510
|
|
|
387
511
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
388
512
|
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
513
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
390
514
|
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>`;
|
|
515
|
+
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
516
|
+
const reach = new Map();
|
|
517
|
+
let reachAt = 0;
|
|
518
|
+
async function probeAll(force) {
|
|
519
|
+
if (!force && Date.now() - reachAt < 20000) return;
|
|
520
|
+
reachAt = Date.now();
|
|
521
|
+
await Promise.all(store.machines.map(async (m, i) => {
|
|
522
|
+
if (i === store.active) return;
|
|
523
|
+
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);
|
|
524
|
+
if (!ok && (await relocate(i))) { reach.set(store.machines[i].url, true); return render(); }
|
|
525
|
+
if (reach.get(m.url) !== ok) { reach.set(m.url, ok); if (state.route.name === 'ordinadors') { const y = scrollY; render(); scrollTo(0, y); } }
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
setInterval(() => { if (!document.hidden && state.route.name === 'ordinadors') probeAll(); }, 30000);
|
|
391
529
|
function computerCard(m, i) {
|
|
392
530
|
const on = i === store.active;
|
|
393
|
-
const
|
|
531
|
+
const pr = reach.get(m.url);
|
|
532
|
+
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>`)
|
|
533
|
+
: 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
534
|
return `<div class="card computer ${on ? 'active' : ''}">
|
|
395
535
|
<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
536
|
<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,9 +548,26 @@ function helpPanel(open) {
|
|
|
408
548
|
${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
549
|
</div></section>`;
|
|
410
550
|
}
|
|
551
|
+
let pushOn = null; // null: encara no ho sé
|
|
552
|
+
async function refreshPush() {
|
|
553
|
+
let on = false;
|
|
554
|
+
try {
|
|
555
|
+
if ('serviceWorker' in navigator && 'PushManager' in window && Notification.permission === 'granted') {
|
|
556
|
+
const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription();
|
|
557
|
+
on = !!sub;
|
|
558
|
+
if (sub && state.connected) api('/api/push', { method: 'POST', body: sub.toJSON() }).catch(() => {});
|
|
559
|
+
}
|
|
560
|
+
} catch { /* sense avisos */ }
|
|
561
|
+
if (on !== pushOn) { pushOn = on; if (state.route.name === 'settings' || state.route.name === 'ajustos') render(); }
|
|
562
|
+
}
|
|
563
|
+
async function disablePush() {
|
|
564
|
+
try { const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); await sub?.unsubscribe(); } catch { /* res */ }
|
|
565
|
+
pushOn = false; toast('Avisos desactivats en aquest dispositiu'); render();
|
|
566
|
+
}
|
|
411
567
|
function viewSettings() {
|
|
412
568
|
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"
|
|
569
|
+
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>` : '';
|
|
570
|
+
if (machine() && pushOn === null) refreshPush();
|
|
414
571
|
return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}</div>`;
|
|
415
572
|
}
|
|
416
573
|
function viewHelp() {
|
|
@@ -430,8 +587,11 @@ function viewComputers() {
|
|
|
430
587
|
<div class="stack">
|
|
431
588
|
<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
589
|
${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>` : ''}
|
|
590
|
+
${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
591
|
</div>
|
|
434
592
|
</div>` : '';
|
|
593
|
+
if (detail && info?.device) loadDevices();
|
|
594
|
+
if (ms.length > 1) probeAll();
|
|
435
595
|
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
|
|
436
596
|
}
|
|
437
597
|
|
|
@@ -534,7 +694,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
534
694
|
if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
|
|
535
695
|
if (e.target.closest('[data-scan]')) {
|
|
536
696
|
const v = $('#scanVideo', sh); v.hidden = false;
|
|
537
|
-
try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet();
|
|
697
|
+
try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); toast('Connectant…'); pairAndAdd(pair); go('#/sessions'); } }); }
|
|
538
698
|
catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
|
|
539
699
|
}
|
|
540
700
|
};
|
|
@@ -556,7 +716,7 @@ function renameSheet(i) {
|
|
|
556
716
|
const m = store.machines[i]; if (!m) return;
|
|
557
717
|
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
718
|
<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);
|
|
719
|
+
const inp = $('#renameIn', sh); if (!isTouch) setTimeout(() => { inp.focus(); inp.select(); }, 60);
|
|
560
720
|
$('#renameForm', sh).onsubmit = (e) => {
|
|
561
721
|
e.preventDefault(); e.stopPropagation();
|
|
562
722
|
const v = inp.value.trim(); if (!v) return;
|
|
@@ -577,25 +737,236 @@ function stateChip(s) {
|
|
|
577
737
|
if (s.state === 'error') return '<span class="chip error">Error</span>';
|
|
578
738
|
return '';
|
|
579
739
|
}
|
|
740
|
+
let manage = null; // Set d'ids seleccionats quan el mode «Gestiona» és actiu
|
|
741
|
+
const DAY = 864e5, money = (n) => (n ? `${n < 0.01 ? '<0,01' : n.toFixed(2).replace('.', ',')} $` : '0 $');
|
|
742
|
+
const manageBar = (list) => {
|
|
743
|
+
const n = manage.size, cost = list.filter((s) => manage.has(s.id)).reduce((t, s) => t + (s.cost || 0), 0);
|
|
744
|
+
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>
|
|
745
|
+
<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>`;
|
|
746
|
+
};
|
|
747
|
+
function manageSelect(kind) {
|
|
748
|
+
const list = [...state.sessions.values()], now = Date.now();
|
|
749
|
+
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];
|
|
750
|
+
manage = new Set(list.filter(f).map((x) => x.id)); render();
|
|
751
|
+
}
|
|
752
|
+
async function manageDelete() {
|
|
753
|
+
const ids = [...manage]; if (!ids.length) return;
|
|
754
|
+
const run = ids.filter((id) => ['running', 'waiting'].includes(state.sessions.get(id)?.state)).length;
|
|
755
|
+
if (!confirm(`S’esborraran ${ids.length} converses${run ? ` (${run} encara treballen i s’aturaran)` : ''}. Els fitxers dels projectes no es toquen.`)) return;
|
|
756
|
+
let fail = 0;
|
|
757
|
+
await Promise.all(ids.map((id) => api(`/api/sessions/${id}`, { method: 'DELETE' }).then(() => handle({ type: 'removed', session: id })).catch(() => { fail++; })));
|
|
758
|
+
manage = fail ? new Set(ids.filter((id) => state.sessions.has(id))) : null;
|
|
759
|
+
toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
|
|
760
|
+
}
|
|
580
761
|
function viewSessions() {
|
|
581
762
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
582
763
|
const many = store.machines.length > 1, mach = machine();
|
|
583
764
|
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
765
|
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" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
|
|
766
|
+
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) : ''}`;
|
|
767
|
+
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
768
|
if (!state.connected && !list.length) {
|
|
587
769
|
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
770
|
}
|
|
771
|
+
if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
|
|
589
772
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
590
|
-
|
|
773
|
+
<${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
774
|
<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
775
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
593
776
|
${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
|
-
|
|
777
|
+
<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>
|
|
778
|
+
</${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
779
|
}
|
|
597
780
|
|
|
598
781
|
/* ---------- Aprovacions */
|
|
782
|
+
/* ---------- Dispositius amb accés */
|
|
783
|
+
const devCache = new Map(); // adreça de l'ordinador -> llista
|
|
784
|
+
let devLoadAt = 0;
|
|
785
|
+
function devicesHtml(devs) {
|
|
786
|
+
if (!devs) return '<p class="muted small">Carregant…</p>';
|
|
787
|
+
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>';
|
|
788
|
+
}
|
|
789
|
+
async function loadDevices(force) {
|
|
790
|
+
const m = machine(); if (!m || (!force && Date.now() - devLoadAt < 10000)) return;
|
|
791
|
+
devLoadAt = Date.now();
|
|
792
|
+
try { devCache.set(m.url, await api('/api/devices')); } catch { devCache.set(m.url, []); }
|
|
793
|
+
const box = $('#devList'); if (box) box.innerHTML = devicesHtml(devCache.get(m.url));
|
|
794
|
+
}
|
|
795
|
+
async function revokeDevice(id) {
|
|
796
|
+
const m = machine(), d = devCache.get(m?.url)?.find((x) => x.id === id);
|
|
797
|
+
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;
|
|
798
|
+
try {
|
|
799
|
+
await api(`/api/devices/${id}`, { method: 'DELETE' }); toast('Accés revocat');
|
|
800
|
+
if (d?.current) return forgetMachine(store.active);
|
|
801
|
+
loadDevices(true);
|
|
802
|
+
} catch (e) { toast(e.message); }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/* ---------- Activitat: registre d'accions de l'ordinador (què, per què, quan i qui) */
|
|
806
|
+
let activity = null, actFilter = 'tot';
|
|
807
|
+
const ACT_FILTERS = { tot: 'Tot', approval: 'Aprovacions', tool: 'Eines', job: 'Feines', device: 'Dispositius' };
|
|
808
|
+
function viewActivity() {
|
|
809
|
+
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>
|
|
810
|
+
<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>
|
|
811
|
+
<div id="actList">${activityHtml()}</div>`;
|
|
812
|
+
}
|
|
813
|
+
function activityHtml() {
|
|
814
|
+
if (!activity) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
815
|
+
const list = activity.filter((e) => actFilter === 'tot' || e.type === actFilter);
|
|
816
|
+
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>`;
|
|
817
|
+
const VERD = { allowed: '<span class="verdict allowed">✓ Aprovat</span>', denied: '<span class="verdict denied">✕ Rebutjat</span>' };
|
|
818
|
+
let day = '';
|
|
819
|
+
return `<div class="act">${list.map((e) => {
|
|
820
|
+
const d = new Date(e.t).toLocaleDateString('ca', { weekday: 'long', day: 'numeric', month: 'long' });
|
|
821
|
+
const head = d !== day ? `<div class="act-day">${esc((day = d))}</div>` : '';
|
|
822
|
+
const when = new Date(e.t).toLocaleTimeString('ca', { hour: '2-digit', minute: '2-digit' });
|
|
823
|
+
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>` : '';
|
|
824
|
+
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>`;
|
|
825
|
+
}).join('')}</div>`;
|
|
826
|
+
}
|
|
827
|
+
async function loadActivity() {
|
|
828
|
+
try { activity = await api('/api/audit?limit=300'); } catch (e) { activity = []; toast(e.message); }
|
|
829
|
+
const box = $('#actList'); if (box) box.innerHTML = activityHtml();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* ---------- Memòria compartida (fitxers markdown a l'ordinador, per a Claude Code i Codex) */
|
|
833
|
+
let mem = null;
|
|
834
|
+
function viewMemory() {
|
|
835
|
+
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>
|
|
836
|
+
<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>
|
|
837
|
+
<div id="memBody">${memHtml()}</div>`;
|
|
838
|
+
}
|
|
839
|
+
function memHtml() {
|
|
840
|
+
if (!mem) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
841
|
+
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>`;
|
|
842
|
+
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>`;
|
|
843
|
+
const groups = {};
|
|
844
|
+
for (const f of mem.files) (groups[f.path.includes('/') ? f.path.split('/')[0] : ''] ||= []).push(f);
|
|
845
|
+
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>';
|
|
846
|
+
}
|
|
847
|
+
async function loadMemory() {
|
|
848
|
+
try { mem = await api('/api/memory'); } catch (e) { mem = { error: e.message }; }
|
|
849
|
+
const box = $('#memBody'); if (box) box.innerHTML = memHtml();
|
|
850
|
+
}
|
|
851
|
+
async function memEdit(p) {
|
|
852
|
+
let f = { path: p || 'projectes/', content: '' };
|
|
853
|
+
if (p) { try { f = await api(`/api/memory/f/${encodeURIComponent(p)}`); } catch (e) { return toast(e.message); } }
|
|
854
|
+
openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2>
|
|
855
|
+
${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" autocapitalize="off" spellcheck="false"></label>'}
|
|
856
|
+
<label class="f"><span>Contingut</span><textarea class="field mono" id="memText" rows="14" spellcheck="false">${esc(f.content)}</textarea></label>
|
|
857
|
+
<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) => {
|
|
858
|
+
$('#memSave', sh).onclick = async () => {
|
|
859
|
+
const path = p || $('#memPath', sh).value.trim();
|
|
860
|
+
try { await api(`/api/memory/f/${encodeURIComponent(path)}`, { method: 'PUT', body: { content: $('#memText', sh).value } }); toast('Desat'); closeSheet(); mem = null; loadMemory(); }
|
|
861
|
+
catch (e) { toast(e.message, 4200); }
|
|
862
|
+
};
|
|
863
|
+
if (p) $('#memDel', sh).onclick = async () => {
|
|
864
|
+
if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
|
|
865
|
+
try { await api(`/api/memory/f/${encodeURIComponent(p)}`, { method: 'DELETE' }); toast('Esborrat'); closeSheet(); loadMemory(); } catch (e) { toast(e.message); }
|
|
866
|
+
};
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
document.addEventListener('click', (e) => { const b = e.target.closest('[data-mem]'); if (b) memEdit(b.dataset.mem); });
|
|
870
|
+
document.addEventListener('submit', async (e) => {
|
|
871
|
+
if (e.target.id !== 'memSearch') return;
|
|
872
|
+
e.preventDefault(); const q = $('#memQ').value.trim(); if (!q) return loadMemory();
|
|
873
|
+
try { mem = { ...(mem || {}), hits: await api(`/api/memory/search?q=${encodeURIComponent(q)}`) }; } catch (err) { toast(err.message); }
|
|
874
|
+
$('#memBody').innerHTML = memHtml();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
/* ---------- Feines: cua, cadenes i programació (l'orquestrador viu a l'ordinador) */
|
|
878
|
+
let jobsData = null;
|
|
879
|
+
const JOB_CHIP = { pendent: '', 'en curs': 'running', feta: 'ok', fallada: 'error', 'cancel·lada': '', programada: 'waiting' };
|
|
880
|
+
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)…']];
|
|
881
|
+
function jobWhen(j) {
|
|
882
|
+
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' }) : '—'}`;
|
|
883
|
+
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' })}`;
|
|
884
|
+
if (j.state === 'pendent' && j.after) return `Quan acabi «${jobsData?.jobs.find((x) => x.id === j.after)?.title || 'la feina anterior'}»`;
|
|
885
|
+
if (j.finished) return ago(j.finished);
|
|
886
|
+
return j.started ? `Des de ${ago(j.started)}` : 'A la cua';
|
|
887
|
+
}
|
|
888
|
+
function jobCard(j) {
|
|
889
|
+
const live = j.state === 'en curs' || j.state === 'pendent' || j.state === 'programada';
|
|
890
|
+
return `<div class="card job" data-job="${esc(j.id)}">
|
|
891
|
+
<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>
|
|
892
|
+
<div class="title">${esc(j.title)}</div>
|
|
893
|
+
<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>
|
|
894
|
+
${j.error && j.state !== 'feta' ? `<div class="muted small" style="margin-top:6px">${esc(j.error)}</div>` : ''}
|
|
895
|
+
<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>
|
|
896
|
+
</div>`;
|
|
897
|
+
}
|
|
898
|
+
function viewJobs() {
|
|
899
|
+
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>`;
|
|
900
|
+
if (!jobsData) return `${head}<div id="jobsBody"><div class="panel empty">${bot('work')}<h2>Carregant…</h2></div></div>`;
|
|
901
|
+
return `${head}<div id="jobsBody">${jobsHtml()}</div>`;
|
|
902
|
+
}
|
|
903
|
+
function jobsHtml() {
|
|
904
|
+
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>`;
|
|
905
|
+
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);
|
|
906
|
+
const sec = (t, l) => (l.length ? `<div class="section-title">${t}</div><div class="list">${l.map(jobCard).join('')}</div>` : '');
|
|
907
|
+
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>`;
|
|
908
|
+
}
|
|
909
|
+
async function loadJobs() {
|
|
910
|
+
try { jobsData = await api('/api/jobs'); } catch (e) { jobsData = { error: e.message, jobs: [] }; }
|
|
911
|
+
const box = $('#jobsBody'); if (box) box.innerHTML = jobsHtml();
|
|
912
|
+
}
|
|
913
|
+
function onJob(msg) {
|
|
914
|
+
if (!jobsData || jobsData.error) return;
|
|
915
|
+
if (msg.removed) jobsData.jobs = jobsData.jobs.filter((j) => j.id !== msg.removed);
|
|
916
|
+
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); }
|
|
917
|
+
const box = $('#jobsBody'); if (box) box.innerHTML = jobsHtml();
|
|
918
|
+
}
|
|
919
|
+
async function newJobSheet() {
|
|
920
|
+
let projects = [];
|
|
921
|
+
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
922
|
+
const agents = (state.info?.agents || []).filter((a) => a.available && ['claude', 'codex'].includes(a.id));
|
|
923
|
+
const prev = (jobsData?.jobs || []).filter((j) => ['pendent', 'en curs', 'feta'].includes(j.state)).slice(0, 15);
|
|
924
|
+
const sel = { agent: 'auto', mode: 'preguntar', when: 'ara' };
|
|
925
|
+
openSheet(`<h2>Nova feina</h2>
|
|
926
|
+
<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>
|
|
927
|
+
<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>
|
|
928
|
+
<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>
|
|
929
|
+
<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>
|
|
930
|
+
<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>
|
|
931
|
+
<div id="jobWhenX"></div></div>
|
|
932
|
+
${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>' : ''}
|
|
933
|
+
<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) => {
|
|
934
|
+
const x = $('#jobWhenX', sh);
|
|
935
|
+
$('#jobWhen', sh).onchange = (e) => {
|
|
936
|
+
sel.when = e.target.value;
|
|
937
|
+
x.innerHTML = sel.when === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
|
|
938
|
+
: 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>`
|
|
939
|
+
: 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">` : '';
|
|
940
|
+
const c = $('#jobCron', sh); if (c) c.onchange = () => ($('#jobCronX', sh).hidden = c.value !== 'custom');
|
|
941
|
+
};
|
|
942
|
+
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(); });
|
|
943
|
+
$('#jobGo', sh).onclick = async () => {
|
|
944
|
+
const prompt = $('#jobPrompt', sh).value.trim(); if (!prompt) return toast('Explica què ha de fer la feina');
|
|
945
|
+
const body = { prompt, agent: $('#jobAgent', sh).value, cwd: $('#jobCwd', sh).value, mode: sel.mode };
|
|
946
|
+
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(); }
|
|
947
|
+
if (sel.when === 'after') body.after = $('#jobAfter', sh).value;
|
|
948
|
+
if (sel.when === 'cron') body.cron = $('#jobCron', sh).value === 'custom' ? $('#jobCronX', sh).value.trim() : $('#jobCron', sh).value;
|
|
949
|
+
$('#jobGo', sh).disabled = true;
|
|
950
|
+
try {
|
|
951
|
+
const j = await api('/api/jobs', { method: 'POST', body });
|
|
952
|
+
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}}' } });
|
|
953
|
+
closeSheet(); toast(body.cron ? 'Feina programada' : 'Feina a la cua'); loadJobs();
|
|
954
|
+
} catch (e) { toast(e.message, 4200); $('#jobGo', sh).disabled = false; }
|
|
955
|
+
};
|
|
956
|
+
if (!isTouch) setTimeout(() => $('#jobPrompt', sh)?.focus(), 80);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
document.addEventListener('click', async (e) => {
|
|
960
|
+
const b = e.target.closest('[data-job-act]'); if (!b) return;
|
|
961
|
+
const id = b.closest('[data-job]').dataset.job, act = b.dataset.jobAct;
|
|
962
|
+
try {
|
|
963
|
+
if (act === 'run') await api(`/api/jobs/${id}/run`, { method: 'POST' });
|
|
964
|
+
if (act === 'cancel') await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
|
965
|
+
if (act === 'del') await api(`/api/jobs/${id}`, { method: 'DELETE' });
|
|
966
|
+
loadJobs();
|
|
967
|
+
} catch (err) { toast(err.message); }
|
|
968
|
+
});
|
|
969
|
+
|
|
599
970
|
function approvalCard(a, showSession = true) {
|
|
600
971
|
const decided = a.state && a.state !== 'pending';
|
|
601
972
|
if (decided && !showSession) {
|
|
@@ -607,13 +978,15 @@ function approvalCard(a, showSession = true) {
|
|
|
607
978
|
${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
979
|
<div class="q">Vol fer servir <span class="mono">${esc(a.tool)}</span></div>
|
|
609
980
|
<pre>${esc(prettyInput(a.input))}</pre>
|
|
981
|
+
${a.reason ? `<div class="why"><span class="muted small">Motiu</span><p>${esc(a.reason)}</p></div>` : ''}
|
|
982
|
+
${a.shot ? `<img class="shot" alt="Captura de la pantalla quan ho ha demanat" data-shot="${esc(a.shot)}">` : ''}
|
|
610
983
|
${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
984
|
</div>`;
|
|
612
985
|
}
|
|
613
986
|
function viewApprovals() {
|
|
614
987
|
const list = [...state.approvals.values()].sort((a, b) => a.created - b.created);
|
|
615
988
|
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>`}`;
|
|
989
|
+
${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
990
|
}
|
|
618
991
|
// Marca una aprovació com a resolta a tot arreu: a la llista, a la conversa i a l'esdeveniment desat.
|
|
619
992
|
function markDecided(id, st) {
|
|
@@ -663,10 +1036,10 @@ function renderSession() {
|
|
|
663
1036
|
feedEls = new Map(); toolEls = new Map();
|
|
664
1037
|
renderSessionHead();
|
|
665
1038
|
const feed = $('#feed'), evs = state.events.get(id);
|
|
666
|
-
if (!evs) feed.innerHTML =
|
|
1039
|
+
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
1040
|
else for (const e of [...evs.values()].sort((a, b) => a.seq - b.seq)) putEvent(e, false);
|
|
668
1041
|
renderTyping(); renderComposer();
|
|
669
|
-
requestAnimationFrame(() => (feed
|
|
1042
|
+
requestAnimationFrame(() => toBottom(feed));
|
|
670
1043
|
}
|
|
671
1044
|
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
672
1045
|
function renderSessionHead() {
|
|
@@ -679,10 +1052,10 @@ function renderSessionHead() {
|
|
|
679
1052
|
if (!s) { bar.innerHTML = ''; return; }
|
|
680
1053
|
const ag = state.info?.agents?.find((a) => a.id === s.agent);
|
|
681
1054
|
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
|
-
<
|
|
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>` : ''}
|
|
1055
|
+
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${svg(CHIP_ICON.mode)}<span>${esc(MODES[s.mode]?.label || s.mode)}</span></button>`}
|
|
1056
|
+
<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>
|
|
1057
|
+
<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>
|
|
1058
|
+
${s.cost ? `<span class="chip mono">${svg(CHIP_ICON.cost)}<span>${s.cost.toFixed(2)} $</span></span>` : ''}
|
|
686
1059
|
${stateChip(s)}`;
|
|
687
1060
|
}
|
|
688
1061
|
// Alçada del camp de text segons el contingut, sense barra de desplaçament fins al límit.
|
|
@@ -692,7 +1065,10 @@ function autogrow(ta, max = 180) {
|
|
|
692
1065
|
ta.style.height = `${Math.min(h, max)}px`;
|
|
693
1066
|
ta.style.overflowY = h > max ? 'auto' : 'hidden';
|
|
694
1067
|
}
|
|
695
|
-
|
|
1068
|
+
// Al mòbil fa scroll el document sencer; a l'escriptori, la conversa té el seu propi espai.
|
|
1069
|
+
const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
|
|
1070
|
+
const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
|
|
1071
|
+
const toBottom = (f) => { const s = scroller(f); s.scrollTop = s.scrollHeight; };
|
|
696
1072
|
function putEvent(e, scroll = true) {
|
|
697
1073
|
const feed = $('#feed'); if (!feed) return;
|
|
698
1074
|
const stick = nearBottom(feed);
|
|
@@ -716,7 +1092,7 @@ function putEvent(e, scroll = true) {
|
|
|
716
1092
|
feedEls.set(e.seq, node);
|
|
717
1093
|
if (e.kind === 'tool') toolEls.set(e.id, node);
|
|
718
1094
|
renderTyping();
|
|
719
|
-
if (scroll && stick) feed
|
|
1095
|
+
if (scroll && stick) toBottom(feed);
|
|
720
1096
|
}
|
|
721
1097
|
function eventNode(e) {
|
|
722
1098
|
const s = state.sessions.get(state.route.id);
|
|
@@ -757,6 +1133,7 @@ const NIO_CMDS = [
|
|
|
757
1133
|
{ name: 'model', hint: 'Canvia de model' },
|
|
758
1134
|
{ name: 'stop', hint: 'Atura l’agent' },
|
|
759
1135
|
{ name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
|
|
1136
|
+
{ name: 'carpeta', hint: 'Canvia la carpeta de la sessió' },
|
|
760
1137
|
{ name: 'git', hint: 'Git i GitHub: commit i PR' },
|
|
761
1138
|
{ name: 'nom', hint: 'Posa nom a la sessió', free: true },
|
|
762
1139
|
{ name: 'opcions', hint: 'Opcions de la sessió' },
|
|
@@ -781,12 +1158,47 @@ async function runCmd(s, name, arg) {
|
|
|
781
1158
|
case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
|
|
782
1159
|
case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
|
|
783
1160
|
case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
|
|
1161
|
+
case 'carpeta': return folderSheet(s);
|
|
784
1162
|
case 'git': return gitSheet(s.id);
|
|
785
1163
|
case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
|
|
786
1164
|
case 'opcions': return sessionOptions(s.id);
|
|
787
1165
|
case 'ajuda': return helpCmdsSheet(s);
|
|
788
1166
|
}
|
|
789
1167
|
}
|
|
1168
|
+
const folderKey = (k) => `nio.${k}.${machine()?.id || machine()?.url}`;
|
|
1169
|
+
const getFavs = () => LS.get(folderKey('favs'), []);
|
|
1170
|
+
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); }
|
|
1171
|
+
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) }); }
|
|
1172
|
+
// Última utilitzada, preferides i recents, sense repeticions.
|
|
1173
|
+
function quickFolders(extra = []) {
|
|
1174
|
+
const l = LS.get(folderKey('last'), {}), favs = getFavs(), seen = new Set(), out = [];
|
|
1175
|
+
const add = (path, tag) => { if (path && !seen.has(path)) { seen.add(path); out.push({ path, name: basename(path), tag }); } };
|
|
1176
|
+
add(l.cwd, 'last'); favs.forEach((p) => add(p, 'fav')); (l.recent || []).forEach((p) => add(p)); extra.forEach((p) => add(p.path));
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
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>`;
|
|
1180
|
+
const favBtn = (p) => `<button class="chip" type="button" data-fav aria-pressed="${getFavs().includes(p)}">${getFavs().includes(p) ? '★ Preferida' : '☆ Desa com a preferida'}</button>`;
|
|
1181
|
+
function folderSheet(s) {
|
|
1182
|
+
let d = null;
|
|
1183
|
+
const draw = () => {
|
|
1184
|
+
const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
|
|
1185
|
+
$('#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>
|
|
1186
|
+
<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>`;
|
|
1187
|
+
const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
|
|
1188
|
+
};
|
|
1189
|
+
const go = async (p) => { try { d = await api(`/api/fs?path=${encodeURIComponent(p || '')}`); } catch (e) { toast(e.message, 4200); } draw(); };
|
|
1190
|
+
openSheet(`<h2>Canvia de carpeta</h2><p class="muted small">Ara: <span class="mono">${esc(s.cwd)}</span>. La conversa continua i l’agent la recorda.</p><div id="fsBody"></div><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="fsOk" type="button" disabled>Treballa aquí</button></div>`, (sh) => {
|
|
1191
|
+
sh.onclick = async (e) => {
|
|
1192
|
+
if (e.target.closest('[data-close]')) return closeSheet();
|
|
1193
|
+
const f = e.target.closest('[data-fs]'); if (f && !f.disabled) return go(f.dataset.fs);
|
|
1194
|
+
if (e.target.closest('[data-fav]') && d) { toggleFav(d.path); return draw(); }
|
|
1195
|
+
if (e.target.closest('#fsOk') && d) {
|
|
1196
|
+
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); }
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
draw(); go(s.cwd);
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
790
1202
|
function helpCmdsSheet(s) {
|
|
791
1203
|
const ac = agentCmds(s.id);
|
|
792
1204
|
openSheet(`<h2>Comandes</h2><p class="muted small">Escriu <span class="mono">/</span> al missatge per veure-les.</p><div class="list">${NIO_CMDS.map((c) => `<div class="card"><b class="mono">/${c.name}</b>${c.args ? ' <span class="muted small mono">l · p · t</span>' : ''}<div class="muted small">${esc(c.hint)}</div></div>`).join('')}</div>${ac.length ? `<p class="muted small">De ${esc(AGENT_NAMES[s.agent] || s.agent)}: ${ac.length} comandes i skills. Se li envien a l’agent tal qual.</p>` : ''}<div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
@@ -798,7 +1210,7 @@ function slashItems(s, v) {
|
|
|
798
1210
|
if (!m) return [];
|
|
799
1211
|
const q = m[1].toLowerCase();
|
|
800
1212
|
const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
|
|
801
|
-
const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
|
|
1213
|
+
const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: (AGENT_NAMES[s.agent] || s.agent).split(' ')[0], agent: true }));
|
|
802
1214
|
const all = [...nio, ...ag], starts = all.filter((c) => c.name.toLowerCase().startsWith(q)), has = all.filter((c) => !c.name.toLowerCase().startsWith(q) && c.name.toLowerCase().includes(q));
|
|
803
1215
|
return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
|
|
804
1216
|
}
|
|
@@ -806,10 +1218,11 @@ function renderComposer() {
|
|
|
806
1218
|
const box = $('#composer'); if (!box) return;
|
|
807
1219
|
const s = state.sessions.get(state.route.id), busy = !!s && (s.state === 'running' || s.state === 'waiting');
|
|
808
1220
|
const prev = $('textarea', box)?.value || '';
|
|
809
|
-
|
|
810
|
-
box.dataset.busy
|
|
1221
|
+
const waiting = s?.state === 'waiting';
|
|
1222
|
+
if (box.dataset.busy === String(busy) + waiting && $('textarea', box)) return;
|
|
1223
|
+
box.dataset.busy = String(busy) + waiting;
|
|
811
1224
|
if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
|
|
812
|
-
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…
|
|
1225
|
+
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>`;
|
|
813
1226
|
const ta = $('#msg'); ta.value = prev;
|
|
814
1227
|
const grow = () => autogrow(ta);
|
|
815
1228
|
let items = [], sel = 0;
|
|
@@ -829,7 +1242,8 @@ function renderComposer() {
|
|
|
829
1242
|
ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
|
|
830
1243
|
};
|
|
831
1244
|
ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
|
|
832
|
-
pop.addEventListener('
|
|
1245
|
+
pop.addEventListener('mousedown', (e) => e.preventDefault());
|
|
1246
|
+
pop.addEventListener('click', (e) => { const b = e.target.closest('[data-i]'); if (b) pick(+b.dataset.i); });
|
|
833
1247
|
const send = async () => {
|
|
834
1248
|
const text = ta.value.trim(); if (!text || !s) return;
|
|
835
1249
|
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
@@ -854,11 +1268,22 @@ function renderComposer() {
|
|
|
854
1268
|
/* ---------- Fulls */
|
|
855
1269
|
function openSheet(html, onMount, cls) {
|
|
856
1270
|
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}`;
|
|
857
|
-
if (!d.open) d.showModal(); raiseToasts();
|
|
1271
|
+
if (!d.open) d.showModal(); raiseToasts();
|
|
1272
|
+
if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
|
|
858
1273
|
onMount?.($('#sheetIn'));
|
|
859
1274
|
}
|
|
860
|
-
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
1275
|
+
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
|
|
1276
|
+
// A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
|
|
1277
|
+
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 */ } } } }
|
|
1278
|
+
function focusMain() {
|
|
1279
|
+
if (isTouch || $('#sheet')?.open || $('#palette')?.open) return;
|
|
1280
|
+
const r = state.route.name;
|
|
1281
|
+
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1282
|
+
else if (r === 'mobil' && local.engine) $('#lmsg')?.focus({ preventScroll: true });
|
|
1283
|
+
else if (r === 'models') $('#pullIn')?.focus({ preventScroll: true });
|
|
1284
|
+
}
|
|
861
1285
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1286
|
+
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
862
1287
|
|
|
863
1288
|
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
864
1289
|
const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
|
|
@@ -936,10 +1361,10 @@ async function newSessionSheet(preset = {}) {
|
|
|
936
1361
|
() => `<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>`,
|
|
937
1362
|
() => {
|
|
938
1363
|
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
939
|
-
const
|
|
1364
|
+
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('');
|
|
940
1365
|
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
941
1366
|
<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>
|
|
942
|
-
<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>
|
|
1367
|
+
<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>
|
|
943
1368
|
<div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
|
|
944
1369
|
${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>` : '')
|
|
945
1370
|
: !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>
|
|
@@ -978,6 +1403,7 @@ async function newSessionSheet(preset = {}) {
|
|
|
978
1403
|
const ag = t.closest('[data-agent-id]');
|
|
979
1404
|
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(); }
|
|
980
1405
|
const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
|
|
1406
|
+
if (t.closest('[data-fav]')) { toggleFav(fsd?.path || sel.cwd); return body(); }
|
|
981
1407
|
if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
|
|
982
1408
|
const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
|
|
983
1409
|
const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
|
|
@@ -1174,6 +1600,7 @@ function onOllama(msg) {
|
|
|
1174
1600
|
|
|
1175
1601
|
/* ---------- Models per a la pissarra */
|
|
1176
1602
|
window.NioBrains = {
|
|
1603
|
+
refresh: () => brainsForBoard(true),
|
|
1177
1604
|
list() {
|
|
1178
1605
|
const out = [{ id: 'rules', label: 'Ordres ràpides' }];
|
|
1179
1606
|
if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
|
|
@@ -1195,9 +1622,12 @@ window.NioBrains = {
|
|
|
1195
1622
|
throw new Error('Aquest model ja no està disponible');
|
|
1196
1623
|
}
|
|
1197
1624
|
};
|
|
1198
|
-
|
|
1625
|
+
let brainsAt = 0;
|
|
1626
|
+
async function brainsForBoard(force) {
|
|
1199
1627
|
if (state.route.name !== 'pissarra') return;
|
|
1200
|
-
|
|
1628
|
+
window.NioBoard?.refreshBrains();
|
|
1629
|
+
if (!machine() || !state.connected || (!force && state.ollama && Date.now() - brainsAt < 30000)) return;
|
|
1630
|
+
try { const o = await api('/api/ollama'); state.ollama = o; brainsAt = Date.now(); } catch { /* sense Ollama */ }
|
|
1201
1631
|
window.NioBoard?.refreshBrains();
|
|
1202
1632
|
}
|
|
1203
1633
|
|
|
@@ -1210,7 +1640,7 @@ async function enablePush() {
|
|
|
1210
1640
|
const reg = await navigator.serviceWorker.ready;
|
|
1211
1641
|
const sub = (await reg.pushManager.getSubscription()) || (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64uToU8(state.info.vapidKey) }));
|
|
1212
1642
|
await api('/api/push', { method: 'POST', body: sub.toJSON() });
|
|
1213
|
-
syncServiceWorker(); toast('Avisos activats');
|
|
1643
|
+
syncServiceWorker(); pushOn = true; toast('Avisos activats'); if (state.route.name === 'settings' || state.route.name === 'ajustos') render();
|
|
1214
1644
|
} catch (e) { toast(e.message, 4200); }
|
|
1215
1645
|
}
|
|
1216
1646
|
async function syncServiceWorker() {
|
|
@@ -1250,7 +1680,7 @@ function viewLocal() {
|
|
|
1250
1680
|
function bindLocal() {
|
|
1251
1681
|
const feed = $('#lfeed'); if (!feed) return;
|
|
1252
1682
|
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>`));
|
|
1253
|
-
feed
|
|
1683
|
+
toBottom(feed);
|
|
1254
1684
|
const ta = $('#lmsg');
|
|
1255
1685
|
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1256
1686
|
ta.addEventListener('input', () => autogrow(ta));
|
|
@@ -1300,7 +1730,7 @@ async function sendLocal() {
|
|
|
1300
1730
|
const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
|
|
1301
1731
|
local.messages.push({ role: 'user', content: text });
|
|
1302
1732
|
feed.appendChild(el(`<div class="msg-user">${esc(text)}</div>`));
|
|
1303
|
-
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); feed
|
|
1733
|
+
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); toBottom(feed);
|
|
1304
1734
|
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1305
1735
|
try {
|
|
1306
1736
|
const chunks = await local.engine.chat.completions.create({
|
|
@@ -1309,7 +1739,7 @@ async function sendLocal() {
|
|
|
1309
1739
|
extra_body: { enable_thinking: false }
|
|
1310
1740
|
});
|
|
1311
1741
|
for await (const c of chunks) {
|
|
1312
|
-
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed
|
|
1742
|
+
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) toBottom(feed);
|
|
1313
1743
|
if (local.stopped) break;
|
|
1314
1744
|
}
|
|
1315
1745
|
} catch (e) {
|
|
@@ -1395,8 +1825,23 @@ function voiceSheet() {
|
|
|
1395
1825
|
}
|
|
1396
1826
|
|
|
1397
1827
|
/* ---------------------------------------------------------------- accions */
|
|
1828
|
+
document.addEventListener('click', (e) => {
|
|
1829
|
+
const f = e.target.closest('[data-filter]'); if (!f) return;
|
|
1830
|
+
actFilter = f.dataset.filter; $$('.act-filter button').forEach((b) => b.setAttribute('aria-pressed', b.dataset.filter === actFilter));
|
|
1831
|
+
const box = $('#actList'); if (box) box.innerHTML = activityHtml();
|
|
1832
|
+
});
|
|
1833
|
+
// Les captures de les aprovacions es demanen amb la clau: no poden anar en un <img src> directe.
|
|
1834
|
+
async function hydrateShots(root = document) {
|
|
1835
|
+
const m = machine(); if (!m) return;
|
|
1836
|
+
for (const img of $$('img[data-shot]:not([src])', root)) {
|
|
1837
|
+
try {
|
|
1838
|
+
const res = await fetch(`${m.url}/api/screen/shot/${encodeURIComponent(img.dataset.shot)}`, { headers: { Authorization: `Bearer ${m.token}` } });
|
|
1839
|
+
if (!res.ok) throw 0; img.src = URL.createObjectURL(await res.blob());
|
|
1840
|
+
} catch { img.remove(); }
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1398
1843
|
document.addEventListener('click', async (e) => {
|
|
1399
|
-
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]');
|
|
1844
|
+
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]');
|
|
1400
1845
|
if (!a) return;
|
|
1401
1846
|
if (a.dataset.copy) {
|
|
1402
1847
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1409,6 +1854,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1409
1854
|
}
|
|
1410
1855
|
if (a.dataset.speak != null) return speak(a);
|
|
1411
1856
|
if (a.dataset.mic != null) return toggleMic();
|
|
1857
|
+
if (a.dataset.man) return manageSelect(a.dataset.man);
|
|
1858
|
+
if (a.dataset.pick) { manage.has(a.dataset.pick) ? manage.delete(a.dataset.pick) : manage.add(a.dataset.pick); return render(); }
|
|
1412
1859
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1413
1860
|
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1414
1861
|
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 }); }
|
|
@@ -1417,6 +1864,9 @@ document.addEventListener('click', async (e) => {
|
|
|
1417
1864
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1418
1865
|
switch (a.dataset.act) {
|
|
1419
1866
|
case 'new': return newSessionSheet();
|
|
1867
|
+
case 'manage-on': manage = new Set(); return render();
|
|
1868
|
+
case 'manage-off': manage = null; return render();
|
|
1869
|
+
case 'manage-del': return manageDelete();
|
|
1420
1870
|
case 'ollama-refresh': state.ollama = null; return loadOllama(true);
|
|
1421
1871
|
case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
|
|
1422
1872
|
case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
|
|
@@ -1428,11 +1878,18 @@ document.addEventListener('click', async (e) => {
|
|
|
1428
1878
|
case 'session-more': return sessionOptions(state.route.id);
|
|
1429
1879
|
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
1430
1880
|
case 'push': return enablePush();
|
|
1881
|
+
case 'push-off': return disablePush();
|
|
1882
|
+
case 'revoke': return revokeDevice(a.dataset.dev);
|
|
1883
|
+
case 'job-new': return newJobSheet();
|
|
1884
|
+
case 'mem-new': return memEdit(null);
|
|
1885
|
+
case 'mem-all': if (mem) delete mem.hits; return loadMemory();
|
|
1886
|
+
case 'activity-refresh': activity = null; return loadActivity();
|
|
1431
1887
|
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;
|
|
1432
1888
|
case 'add-computer': return addComputerSheet();
|
|
1433
1889
|
case 'open-sessions': return go('#/sessions');
|
|
1434
1890
|
case 'help': return go('#/ajuda');
|
|
1435
1891
|
case 'more': return moreSheet();
|
|
1892
|
+
case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
|
|
1436
1893
|
case 'machines': return machinesSheet();
|
|
1437
1894
|
case 'mobile-qr': return mobileQrSheet();
|
|
1438
1895
|
case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
|
|
@@ -1452,7 +1909,7 @@ document.addEventListener('submit', (e) => {
|
|
|
1452
1909
|
e.preventDefault();
|
|
1453
1910
|
const pair = pairFrom($('#pairInput', e.target).value);
|
|
1454
1911
|
if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
|
|
1455
|
-
closeSheet();
|
|
1912
|
+
closeSheet(); toast('Connectant…'); pairAndAdd(pair); go('#/sessions');
|
|
1456
1913
|
});
|
|
1457
1914
|
|
|
1458
1915
|
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
@@ -1612,7 +2069,8 @@ $('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false),
|
|
|
1612
2069
|
// Al mòbil, l'alçada visible real (amb el teclat obert o tancat), perquè el camp de text quedi sempre just a sota.
|
|
1613
2070
|
if (window.visualViewport) {
|
|
1614
2071
|
const vv = visualViewport, root = document.documentElement;
|
|
1615
|
-
|
|
2072
|
+
// A l'iPhone el teclat no encongeix la pàgina: el camp d'escriure puja el que ocupi el teclat (--kb).
|
|
2073
|
+
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); };
|
|
1616
2074
|
vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); fit();
|
|
1617
2075
|
}
|
|
1618
2076
|
if ('serviceWorker' in navigator) {
|
|
@@ -1636,3 +2094,59 @@ if (!checkPairHash()) state.route = parseRoute();
|
|
|
1636
2094
|
if (machine()) connect();
|
|
1637
2095
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
1638
2096
|
if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
|
|
2097
|
+
|
|
2098
|
+
/* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
|
|
2099
|
+
(() => {
|
|
2100
|
+
const PAGES = ['sessions', 'approvals', 'ordinadors'];
|
|
2101
|
+
const view = $('#view');
|
|
2102
|
+
let g = null, timer = 0;
|
|
2103
|
+
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; };
|
|
2104
|
+
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);
|
|
2105
|
+
const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
|
|
2106
|
+
const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
|
|
2107
|
+
const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
|
|
2108
|
+
addEventListener('touchstart', (e) => {
|
|
2109
|
+
g = null;
|
|
2110
|
+
if (e.touches.length !== 1 || !view.contains(e.target) || !PAGES.includes(state.route.name) || blocked(e.target)) return;
|
|
2111
|
+
const t = e.touches[0];
|
|
2112
|
+
if (t.clientX < 24 || t.clientX > innerWidth - 24) return;
|
|
2113
|
+
clearTimeout(timer); clear();
|
|
2114
|
+
g = { x: t.clientX, y: t.clientY, t: e.timeStamp, dx: 0, lock: null };
|
|
2115
|
+
}, { passive: true });
|
|
2116
|
+
addEventListener('touchmove', (e) => {
|
|
2117
|
+
if (!g) return;
|
|
2118
|
+
const t = e.touches[0], dx = t.clientX - g.x, dy = t.clientY - g.y;
|
|
2119
|
+
if (!g.lock) {
|
|
2120
|
+
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
|
|
2121
|
+
g.lock = Math.abs(dx) > Math.abs(dy) * 1.3 ? 'x' : 'y';
|
|
2122
|
+
if (g.lock === 'x') view.classList.add('swiping');
|
|
2123
|
+
}
|
|
2124
|
+
if (g.lock !== 'x') { g = null; return; }
|
|
2125
|
+
if (e.cancelable) e.preventDefault();
|
|
2126
|
+
g.dx = dx;
|
|
2127
|
+
const ok = target(dx < 0 ? 1 : -1);
|
|
2128
|
+
put(dx * (ok ? 0.8 : 0.2), 1 - Math.min(Math.abs(dx) / innerWidth, 0.6) * (ok ? 0.7 : 0.2));
|
|
2129
|
+
}, { passive: false });
|
|
2130
|
+
const end = (e) => {
|
|
2131
|
+
if (!g || g.lock !== 'x') { g = null; return; }
|
|
2132
|
+
const { dx } = g, v = Math.abs(dx) / Math.max(e.timeStamp - g.t, 1), dir = dx < 0 ? 1 : -1, to = target(dir);
|
|
2133
|
+
g = null;
|
|
2134
|
+
const far = Math.abs(dx) > 56 || (Math.abs(dx) > 20 && v > 0.35);
|
|
2135
|
+
if (!to || !far) {
|
|
2136
|
+
view.classList.add('swipe-anim'); put(0, 1);
|
|
2137
|
+
timer = setTimeout(clear, 200);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
navigator.vibrate?.(6);
|
|
2141
|
+
view.classList.remove('swipe-anim');
|
|
2142
|
+
addEventListener('hashchange', () => {
|
|
2143
|
+
if (reduce) { clear(); return; }
|
|
2144
|
+
put(dir * innerWidth * 0.35, 0.2); view.offsetWidth;
|
|
2145
|
+
view.classList.add('swipe-anim'); put(0, 1);
|
|
2146
|
+
timer = setTimeout(clear, 200);
|
|
2147
|
+
}, { once: true });
|
|
2148
|
+
go(HASH[to]);
|
|
2149
|
+
};
|
|
2150
|
+
addEventListener('touchend', end, { passive: true });
|
|
2151
|
+
addEventListener('touchcancel', () => { if (g?.lock === 'x') { view.classList.add('swipe-anim'); put(0, 1); timer = setTimeout(clear, 200); } g = null; }, { passive: true });
|
|
2152
|
+
})();
|