nio-cat 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hub.js +5 -2
- package/src/server.js +1 -0
- package/web/app/app.js +82 -36
- package/web/app/style.css +16 -1
- package/web/sw.js +1 -1
package/package.json
CHANGED
package/src/hub.js
CHANGED
|
@@ -99,7 +99,10 @@ class Hub extends EventEmitter {
|
|
|
99
99
|
|
|
100
100
|
push(s, kind, data = {}) {
|
|
101
101
|
const last = s.events[s.events.length - 1];
|
|
102
|
-
if (last && last.open && kind !== 'text-chunk')
|
|
102
|
+
if (last && last.open && kind !== 'text-chunk') {
|
|
103
|
+
delete last.open;
|
|
104
|
+
this.emit('out', { type: 'event', session: s.id, event: last });
|
|
105
|
+
}
|
|
103
106
|
const ev = { seq: ++s.seq, t: Date.now(), kind, ...data };
|
|
104
107
|
s.events.push(ev);
|
|
105
108
|
if (s.events.length > MAX_EVENTS) s.events.splice(0, s.events.length - MAX_EVENTS);
|
|
@@ -113,7 +116,7 @@ class Hub extends EventEmitter {
|
|
|
113
116
|
const last = s.events[s.events.length - 1];
|
|
114
117
|
if (last && last.kind === 'text' && last.open) {
|
|
115
118
|
last.text += text;
|
|
116
|
-
this.emit('out', { type: '
|
|
119
|
+
this.emit('out', { type: 'delta', session: s.id, seq: last.seq, text });
|
|
117
120
|
s.updated = Date.now();
|
|
118
121
|
this.persist(s);
|
|
119
122
|
return;
|
package/src/server.js
CHANGED
|
@@ -452,6 +452,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
|
|
|
452
452
|
'x-accel-buffering': 'no',
|
|
453
453
|
connection: 'keep-alive',
|
|
454
454
|
});
|
|
455
|
+
req.socket?.setNoDelay(true);
|
|
455
456
|
res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals() })}\n\n`);
|
|
456
457
|
const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
|
|
457
458
|
if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
|
package/web/app/app.js
CHANGED
|
@@ -168,9 +168,10 @@ async function pollLoop(ctrl) {
|
|
|
168
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
169
|
const evs = r.name === 'session' && state.events.get(r.id);
|
|
170
170
|
if (evs && state.sessions.has(r.id)) {
|
|
171
|
-
const
|
|
171
|
+
const open = [...evs.values()].filter((e) => e.open).map((e) => e.seq);
|
|
172
|
+
const since = open.length ? Math.min(...open) - 1 : Math.max(0, ...evs.keys());
|
|
172
173
|
const d = await api(`/api/sessions/${r.id}?since=${since}`);
|
|
173
|
-
for (const e of d.events)
|
|
174
|
+
for (const e of d.events) { const old = evs.get(e.seq); if (!old || old.open || old.text !== e.text) handle({ type: 'event', session: r.id, event: e }); }
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
} catch (e) {
|
|
@@ -325,6 +326,11 @@ function handle(msg) {
|
|
|
325
326
|
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); } }
|
|
326
327
|
break;
|
|
327
328
|
}
|
|
329
|
+
case 'delta': {
|
|
330
|
+
const e = state.events.get(msg.session)?.get(msg.seq);
|
|
331
|
+
if (e) { e.text += msg.text; if (r.name === 'session' && r.id === msg.session) streamText(msg.session, e); }
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
328
334
|
case 'ollama': onOllama(msg); break;
|
|
329
335
|
case 'job': onJob(msg); break;
|
|
330
336
|
case 'screen': onScreen(msg); break;
|
|
@@ -429,22 +435,21 @@ const VIEWS = {
|
|
|
429
435
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
430
436
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
431
437
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
432
|
-
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Feina' },
|
|
433
438
|
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
|
|
434
|
-
pantalla: { label: 'Pantalla', icon: ICON.screen, group: 'Ordinadors' },
|
|
435
439
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
436
|
-
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
437
440
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
438
441
|
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' },
|
|
442
|
+
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Compte' },
|
|
439
443
|
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
440
444
|
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
441
445
|
};
|
|
442
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria',
|
|
443
|
-
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', '
|
|
446
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
447
|
+
const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', 'session']);
|
|
444
448
|
function parseRoute() {
|
|
445
449
|
const h = location.hash.replace(/^#\/?/, '');
|
|
446
450
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
447
|
-
if (h === 'ordinador') return { name: 'ordinadors' }; // adreces antigues
|
|
451
|
+
if (h === 'ordinador' || h === 'pantalla') return { name: 'ordinadors' }; // adreces antigues
|
|
452
|
+
if (h === 'models') return { name: 'mobil' };
|
|
448
453
|
if (h === 'mobil') return { name: 'mobil' };
|
|
449
454
|
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
450
455
|
return { name: k || 'sessions' };
|
|
@@ -454,6 +459,8 @@ let lastName = null;
|
|
|
454
459
|
function route() {
|
|
455
460
|
if (checkPairHash()) return;
|
|
456
461
|
state.route = parseRoute();
|
|
462
|
+
const canon = state.route.name === 'session' ? null : HASH[state.route.name];
|
|
463
|
+
if (canon && location.hash && location.hash !== canon && /^#\/(pantalla|models|ordinador)$/.test(location.hash)) history.replaceState(null, '', canon);
|
|
457
464
|
// Sense cap ordinador, tot porta al mateix lloc: la pàgina Ordinadors.
|
|
458
465
|
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/ordinadors'); state.route = { name: 'ordinadors' }; }
|
|
459
466
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
@@ -472,7 +479,7 @@ function renderChrome() {
|
|
|
472
479
|
const m = machine(), sc = $('#sideConn');
|
|
473
480
|
sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(m.name)}</span>${svg(ICON.chev)}` : `<i class="dot"></i><span>Afegeix un ordinador</span>${svg(ICON.plus)}`;
|
|
474
481
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
475
|
-
const more = ['mobil', 'pissarra', 'ajustos', 'ajuda'].includes(r.name);
|
|
482
|
+
const more = ['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
476
483
|
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
477
484
|
.map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
|
|
478
485
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
@@ -499,12 +506,12 @@ function render() {
|
|
|
499
506
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
500
507
|
else {
|
|
501
508
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
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 === '
|
|
509
|
+
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 === 'ajuda' ? viewHelp() : viewSettings()}</div>`;
|
|
503
510
|
if (r.name === 'mobil') bindLocal();
|
|
504
|
-
if (r.name === '
|
|
511
|
+
if (r.name === 'mobil' && machine() && !local.engine) loadOllama();
|
|
505
512
|
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
506
513
|
if (r.name === 'memoria' && machine()) loadMemory();
|
|
507
|
-
if (r.name === '
|
|
514
|
+
if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
|
|
508
515
|
if (r.name === 'feines' && machine()) loadJobs();
|
|
509
516
|
hydrateShots();
|
|
510
517
|
}
|
|
@@ -599,7 +606,7 @@ function viewComputers() {
|
|
|
599
606
|
</div>` : '';
|
|
600
607
|
if (detail && info?.device) loadDevices();
|
|
601
608
|
if (ms.length > 1) probeAll();
|
|
602
|
-
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
|
|
609
|
+
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${detail ? screenSection() : ''}`;
|
|
603
610
|
}
|
|
604
611
|
|
|
605
612
|
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
@@ -734,7 +741,7 @@ function renameSheet(i) {
|
|
|
734
741
|
}
|
|
735
742
|
function moreSheet() {
|
|
736
743
|
const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
|
|
737
|
-
openSheet(`<h2>Més</h2><div class="list">${['mobil', 'pissarra', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
744
|
+
openSheet(`<h2>Més</h2><div class="list">${['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
738
745
|
}
|
|
739
746
|
|
|
740
747
|
/* ---------- Sessions */
|
|
@@ -840,16 +847,16 @@ async function loadActivity() {
|
|
|
840
847
|
let mem = null;
|
|
841
848
|
function viewMemory() {
|
|
842
849
|
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="
|
|
850
|
+
<form class="mem-search" id="memSearch" role="search"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria" autocomplete="off"><button class="btn ink" type="submit">Busca</button></form>
|
|
844
851
|
<div id="memBody">${memHtml()}</div>`;
|
|
845
852
|
}
|
|
846
853
|
function memHtml() {
|
|
847
854
|
if (!mem) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
848
855
|
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>`;
|
|
856
|
+
if (mem.hits) return `<div class="list mem-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
857
|
const groups = {};
|
|
851
858
|
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)}"
|
|
859
|
+
return Object.entries(groups).map(([g, fs]) => `${g ? `<div class="section-title">${esc(g)}</div>` : ''}<div class="list mem-list">${fs.map((f) => `<button class="card row" type="button" data-mem="${esc(f.path)}"><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
860
|
}
|
|
854
861
|
async function loadMemory() {
|
|
855
862
|
try { mem = await api('/api/memory'); } catch (e) { mem = { error: e.message }; }
|
|
@@ -1040,7 +1047,7 @@ function renderSession() {
|
|
|
1040
1047
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
1041
1048
|
<div class="composer" id="composer"></div>
|
|
1042
1049
|
</section>`;
|
|
1043
|
-
feedEls = new Map(); toolEls = new Map();
|
|
1050
|
+
feedEls = new Map(); toolEls = new Map(); typers.clear();
|
|
1044
1051
|
renderSessionHead();
|
|
1045
1052
|
const feed = $('#feed'), evs = state.events.get(id);
|
|
1046
1053
|
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>`;
|
|
@@ -1076,8 +1083,50 @@ function autogrow(ta, max = 180) {
|
|
|
1076
1083
|
const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
|
|
1077
1084
|
const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
|
|
1078
1085
|
const toBottom = (f) => { const s = scroller(f); s.scrollTop = s.scrollHeight; };
|
|
1086
|
+
// El text de l'agent surt paraula per paraula, tal com arriba, sense refer tota la bombolla a cada tros.
|
|
1087
|
+
const typers = new Map();
|
|
1088
|
+
function wordsAfter(text, i, n) {
|
|
1089
|
+
for (; n > 0 && i < text.length; n--) { while (i < text.length && /\s/.test(text[i])) i++; while (i < text.length && !/\s/.test(text[i])) i++; }
|
|
1090
|
+
return i;
|
|
1091
|
+
}
|
|
1092
|
+
function streamText(sid, e) {
|
|
1093
|
+
const feed = $('#feed'); if (!feed) return;
|
|
1094
|
+
let t = typers.get(e.seq);
|
|
1095
|
+
if (!t) {
|
|
1096
|
+
const node = eventNode({ ...e, text: '' }); if (!node) return;
|
|
1097
|
+
const typing = $('.typing', feed); typing ? feed.insertBefore(node, typing) : feed.appendChild(node);
|
|
1098
|
+
feedEls.get(e.seq)?.remove(); feedEls.set(e.seq, node);
|
|
1099
|
+
t = { sid, seq: e.seq, node, shown: Math.max(0, e.text.length - 40), last: 0, raf: 0 };
|
|
1100
|
+
typers.set(e.seq, t);
|
|
1101
|
+
}
|
|
1102
|
+
if (!t.raf) t.raf = requestAnimationFrame(typeTick(t));
|
|
1103
|
+
renderTyping();
|
|
1104
|
+
}
|
|
1105
|
+
const typeTick = (t) => function tick(now) {
|
|
1106
|
+
t.raf = 0;
|
|
1107
|
+
const e = state.events.get(t.sid)?.get(t.seq), feed = $('#feed');
|
|
1108
|
+
if (!e || !feed || !feed.contains(t.node)) { typers.delete(t.seq); return; }
|
|
1109
|
+
if (now - t.last >= 28) {
|
|
1110
|
+
t.last = now;
|
|
1111
|
+
const backlog = e.text.length - t.shown;
|
|
1112
|
+
if (backlog > 0) {
|
|
1113
|
+
const stick = nearBottom(feed);
|
|
1114
|
+
t.shown = wordsAfter(e.text, t.shown, 1 + Math.floor(backlog / 80));
|
|
1115
|
+
t.node.innerHTML = md(e.text.slice(0, t.shown));
|
|
1116
|
+
if (stick) feed.scrollTop = feed.scrollHeight;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (!e.open && t.shown >= e.text.length) {
|
|
1120
|
+
typers.delete(t.seq);
|
|
1121
|
+
const node = eventNode(e); t.node.replaceWith(node); feedEls.set(e.seq, node);
|
|
1122
|
+
renderTyping(); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
t.raf = requestAnimationFrame(tick);
|
|
1126
|
+
};
|
|
1079
1127
|
function putEvent(e, scroll = true) {
|
|
1080
1128
|
const feed = $('#feed'); if (!feed) return;
|
|
1129
|
+
if (e.kind === 'text' && scroll && (e.open || typers.has(e.seq))) return streamText(state.route.id, e);
|
|
1081
1130
|
const stick = nearBottom(feed);
|
|
1082
1131
|
if (e.kind === 'tool_result') {
|
|
1083
1132
|
const t = toolEls.get(e.id);
|
|
@@ -1287,7 +1336,6 @@ function focusMain() {
|
|
|
1287
1336
|
const r = state.route.name;
|
|
1288
1337
|
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1289
1338
|
else if (r === 'mobil' && local.engine) $('#lmsg')?.focus({ preventScroll: true });
|
|
1290
|
-
else if (r === 'models') $('#pullIn')?.focus({ preventScroll: true });
|
|
1291
1339
|
}
|
|
1292
1340
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1293
1341
|
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
@@ -1539,8 +1587,9 @@ async function loadOllama(force) {
|
|
|
1539
1587
|
catch (e) { state.ollama = { available: false, models: [], running: [], pulling: [], error: e.message }; }
|
|
1540
1588
|
paintModels();
|
|
1541
1589
|
}
|
|
1542
|
-
function
|
|
1543
|
-
return `<div class="
|
|
1590
|
+
function ollamaSection() {
|
|
1591
|
+
if (!machine()) return `<div class="sec-head"><div><h2 class="sec">Ollama a l’ordinador</h2><p class="muted small">Els models d’Ollama són més grans i capaços, però només funcionen en un ordinador. Connecta’n un per veure’ls i descarregar-ne.</p></div><a class="btn" href="#/ordinadors">Ordinadors</a></div>`;
|
|
1592
|
+
return `<div class="sec-head"><div><h2 class="sec">Ollama a l’ordinador</h2><p class="muted small">Models que funcionen a ${esc(machine().name)}, sense internet i sense comptes. Els fas servir des de Sessions.</p></div><button class="btn" data-act="ollama-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div><div id="modelsBody">${state.ollama ? '' : `<div class="panel empty">${bot('work')}<h2>Consultant Ollama…</h2></div>`}</div>`;
|
|
1544
1593
|
}
|
|
1545
1594
|
function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
|
|
1546
1595
|
function paintModels() {
|
|
@@ -1595,14 +1644,14 @@ function onOllama(msg) {
|
|
|
1595
1644
|
state.ollama.pulling = list.filter((x) => x.name !== msg.name);
|
|
1596
1645
|
toast(msg.error ? `${msg.name}: ${msg.error}` : `${msg.name} ja està llest`, 4200);
|
|
1597
1646
|
if (!msg.error) api('/api/info?refresh=1').then((i) => (state.info = i)).catch(() => {});
|
|
1598
|
-
if (state.route.name === '
|
|
1647
|
+
if (state.route.name === 'mobil') loadOllama(true);
|
|
1599
1648
|
return;
|
|
1600
1649
|
}
|
|
1601
1650
|
if (!j) { j = { name: msg.name }; list.push(j); }
|
|
1602
1651
|
Object.assign(j, { status: msg.status, completed: msg.completed, total: msg.total });
|
|
1603
1652
|
const row = $(`[data-pull="${CSS.escape(msg.name)}"]`);
|
|
1604
1653
|
if (row) { $('.progress > i', row).style.width = pctOf(j) + '%'; $('[data-st]', row).textContent = `${j.status}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}`; }
|
|
1605
|
-
else if (state.route.name === '
|
|
1654
|
+
else if (state.route.name === 'mobil') paintModels();
|
|
1606
1655
|
}
|
|
1607
1656
|
|
|
1608
1657
|
/* ---------- Models per a la pissarra */
|
|
@@ -1669,12 +1718,13 @@ function viewLocal() {
|
|
|
1669
1718
|
const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
1670
1719
|
if (!local.engine) {
|
|
1671
1720
|
return `<div class="top"><div><h1>Xat local</h1><p>Un model dins d’aquest dispositiu, per quan no tens l’ordinador a mà. Un cop descarregat, funciona sense connexió i res no surt del dispositiu.</p></div></div>
|
|
1721
|
+
<h2 class="sec" style="margin-top:0">En aquest dispositiu</h2>
|
|
1672
1722
|
<div class="stack-lg" style="max-width:640px">
|
|
1673
1723
|
${hasGpu ? '' : '<div class="panel"><header><h2>Aquest navegador no té WebGPU</h2></header><div class="body"><p class="muted small">Prova amb Chrome a Android, Safari recent a iPhone o Chrome i Edge a l’ordinador. També pots fer servir Ollama des d’un ordinador connectat.</p></div></div>'}
|
|
1674
1724
|
<div class="list">${LOCAL_MODELS.map((m) => `<button class="card model-card" type="button" data-lm="${m.id}" aria-pressed="${m.id === chosen}"><div class="row between"><b>${m.name}</b><span class="muted small mono">${m.size}</span></div><div class="muted small" style="margin-top:4px">${m.note}</div></button>`).join('')}</div>
|
|
1675
|
-
<a class="card" href="#/models"><b>Tens Ollama a l’ordinador?</b><div class="muted small" style="margin-top:4px">Els models d’Ollama són més grans i capaços. Funcionen a l’ordinador i els fas servir des d’aquí.</div></a>
|
|
1676
1725
|
${local.loading ? `<div class="stack"><div class="progress"><i style="width:${Math.round(local.progress * 100)}%"></i></div><p class="muted small mono" id="lmStatus">${esc(local.status)}</p></div>` : `<div class="stack"><button class="btn ink block" data-act="lm-load" type="button" ${hasGpu ? '' : 'disabled'}>Descarrega i obre el model</button><p class="muted small">La primera vegada es descarrega i queda desat al navegador.</p></div>`}
|
|
1677
|
-
</div
|
|
1726
|
+
</div>
|
|
1727
|
+
${ollamaSection()}`;
|
|
1678
1728
|
}
|
|
1679
1729
|
const info = LOCAL_MODELS.find((m) => m.id === local.model);
|
|
1680
1730
|
return `<section class="chat">
|
|
@@ -1773,9 +1823,8 @@ async function panicNow() {
|
|
|
1773
1823
|
if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
|
|
1774
1824
|
try { const r = await api('/api/panic', { method: 'POST' }); toast(`Aturat tot (${r.stopped} agents)`); } catch (e) { toast(e.message, 4500); }
|
|
1775
1825
|
}
|
|
1776
|
-
function
|
|
1777
|
-
|
|
1778
|
-
return `${head}<div id="screenBody">${screenHtml()}</div>`;
|
|
1826
|
+
function screenSection() {
|
|
1827
|
+
return `<div class="sec-head" id="screenSec"><div><h2 class="sec">Pantalla</h2><p class="muted small">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" data-act="screen-now" type="button" ${controlOn() ? '' : 'disabled'}>${svg(ICON.screen)}Veure pantalla ara</button></div><div id="screenBody">${screenHtml()}</div>`;
|
|
1779
1828
|
}
|
|
1780
1829
|
function screenHtml() {
|
|
1781
1830
|
if (!screen) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
@@ -1785,13 +1834,11 @@ function screenHtml() {
|
|
|
1785
1834
|
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
1835
|
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
1836
|
};
|
|
1788
|
-
return `<div class="grid2">
|
|
1837
|
+
return `<div class="grid2 one">
|
|
1789
1838
|
<div class="stack">
|
|
1839
|
+
<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>
|
|
1790
1840
|
<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
1841
|
<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
1842
|
<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
1843
|
<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
1844
|
</div>
|
|
@@ -1806,7 +1853,7 @@ async function loadScreen() {
|
|
|
1806
1853
|
renderPanic();
|
|
1807
1854
|
}
|
|
1808
1855
|
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;
|
|
1856
|
+
function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
|
|
1810
1857
|
document.addEventListener('click', async (e) => {
|
|
1811
1858
|
const b = e.target.closest('[data-ctl]'); if (!b) return;
|
|
1812
1859
|
b.disabled = true;
|
|
@@ -2032,7 +2079,7 @@ document.addEventListener('submit', (e) => {
|
|
|
2032
2079
|
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
2033
2080
|
let palSel = 0;
|
|
2034
2081
|
function commands() {
|
|
2035
|
-
const c = Object.entries(VIEWS).
|
|
2082
|
+
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
2036
2083
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
2037
2084
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
2038
2085
|
[...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
|
|
@@ -2094,8 +2141,7 @@ const TSTEPS = [
|
|
|
2094
2141
|
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '2. Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
|
|
2095
2142
|
{ route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: '3. Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (qualsevol de l’ordinador), els permisos i li expliques què ha de fer.' },
|
|
2096
2143
|
{ route: 'approvals', skipIf: () => !machine(), target: '.side nav a[href="#/aprovacions"]', m: '#tabbar a[href="#/aprovacions"]', title: '4. Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
|
|
2097
|
-
{ route: '
|
|
2098
|
-
{ route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
|
|
2144
|
+
{ route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Models sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil. Si tens Ollama a l’ordinador, aquí hi descarregues models més grans.' },
|
|
2099
2145
|
{ route: 'pissarra', target: '.side nav a[href="#/pissarra"]', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama o amb el model del mòbil.' },
|
|
2100
2146
|
{ route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
|
|
2101
2147
|
{ target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens el xat local, la pissarra, els ajustos i l’ajuda, amb aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
|
package/web/app/style.css
CHANGED
|
@@ -109,7 +109,7 @@ kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--bord
|
|
|
109
109
|
.icon-btn { position: relative; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: none; color: var(--fg); }
|
|
110
110
|
.icon-btn:hover { background: color-mix(in srgb, var(--bg-2) 80%, transparent); }
|
|
111
111
|
.icon-btn .badge { position: absolute; top: 3px; right: 1px; min-width: 18px; height: 18px; font-size: 10px; }
|
|
112
|
-
input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); outline: none; }
|
|
112
|
+
input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); color: var(--fg); outline: none; }
|
|
113
113
|
textarea.field { height: auto; padding: 10px 12px; resize: vertical; line-height: 1.45; }
|
|
114
114
|
input:focus, select:focus, textarea:focus { border-color: var(--fg); }
|
|
115
115
|
select { appearance: none; -webkit-appearance: none; padding-right: 40px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2369615c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px 16px; }
|
|
@@ -630,3 +630,18 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
|
|
|
630
630
|
.panic { position: fixed; z-index: 35; right: 16px; top: calc(66px + var(--safe-t)); display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px 0 9px; border: 0; border-radius: 999px; background: var(--red); color: #fff; font: 600 13px var(--font); box-shadow: 0 8px 20px -10px color-mix(in srgb, var(--red) 80%, transparent); }
|
|
631
631
|
.panic .ico { width: 17px; height: 17px; }
|
|
632
632
|
@media (min-width: 821px) { .panic { top: 16px; right: 20px; } }
|
|
633
|
+
|
|
634
|
+
/* Memòria */
|
|
635
|
+
.mem-search { display: flex; gap: 10px; align-items: stretch; margin: 0 0 24px; }
|
|
636
|
+
.mem-search input { flex: 1; height: 46px; color: var(--fg); }
|
|
637
|
+
.mem-search input::placeholder { color: var(--fg-muted); opacity: 1; }
|
|
638
|
+
.mem-search .btn { height: 46px; padding: 0 22px; flex: none; }
|
|
639
|
+
.mem-list { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
|
640
|
+
.mem-list .card { color: var(--fg); }
|
|
641
|
+
.mem-list .card b { color: var(--fg); }
|
|
642
|
+
.section-title + .mem-list { margin-bottom: 8px; }
|
|
643
|
+
|
|
644
|
+
/* Capçalera d'una secció dins d'una pàgina */
|
|
645
|
+
.sec-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin: 36px 0 14px; }
|
|
646
|
+
.sec-head .sec { margin: 0; }
|
|
647
|
+
.sec-head p { margin-top: 4px; max-width: 640px; }
|
package/web/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
|
|
2
|
-
const SHELL = 'nio-shell-
|
|
2
|
+
const SHELL = 'nio-shell-v22';
|
|
3
3
|
const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
4
4
|
|
|
5
5
|
self.addEventListener('install', (e) => {
|