nio-cat 0.8.1 → 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 +1 -1
- package/src/server.js +1 -0
- package/web/app/app.js +79 -34
- package/web/app/style.css +16 -1
- package/web/sw.js +1 -1
package/package.json
CHANGED
package/src/hub.js
CHANGED
|
@@ -116,7 +116,7 @@ class Hub extends EventEmitter {
|
|
|
116
116
|
const last = s.events[s.events.length - 1];
|
|
117
117
|
if (last && last.kind === 'text' && last.open) {
|
|
118
118
|
last.text += text;
|
|
119
|
-
this.emit('out', { type: '
|
|
119
|
+
this.emit('out', { type: 'delta', session: s.id, seq: last.seq, text });
|
|
120
120
|
s.updated = Date.now();
|
|
121
121
|
this.persist(s);
|
|
122
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
|
@@ -326,6 +326,11 @@ function handle(msg) {
|
|
|
326
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); } }
|
|
327
327
|
break;
|
|
328
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
|
+
}
|
|
329
334
|
case 'ollama': onOllama(msg); break;
|
|
330
335
|
case 'job': onJob(msg); break;
|
|
331
336
|
case 'screen': onScreen(msg); break;
|
|
@@ -430,22 +435,21 @@ const VIEWS = {
|
|
|
430
435
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
431
436
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
432
437
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
433
|
-
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Feina' },
|
|
434
438
|
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
|
|
435
|
-
pantalla: { label: 'Pantalla', icon: ICON.screen, group: 'Ordinadors' },
|
|
436
439
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
437
|
-
models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
|
|
438
440
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
439
441
|
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' },
|
|
442
|
+
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Compte' },
|
|
440
443
|
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
441
444
|
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
442
445
|
};
|
|
443
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria',
|
|
444
|
-
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']);
|
|
445
448
|
function parseRoute() {
|
|
446
449
|
const h = location.hash.replace(/^#\/?/, '');
|
|
447
450
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
448
|
-
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' };
|
|
449
453
|
if (h === 'mobil') return { name: 'mobil' };
|
|
450
454
|
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
451
455
|
return { name: k || 'sessions' };
|
|
@@ -455,6 +459,8 @@ let lastName = null;
|
|
|
455
459
|
function route() {
|
|
456
460
|
if (checkPairHash()) return;
|
|
457
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);
|
|
458
464
|
// Sense cap ordinador, tot porta al mateix lloc: la pàgina Ordinadors.
|
|
459
465
|
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/ordinadors'); state.route = { name: 'ordinadors' }; }
|
|
460
466
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
@@ -473,7 +479,7 @@ function renderChrome() {
|
|
|
473
479
|
const m = machine(), sc = $('#sideConn');
|
|
474
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)}`;
|
|
475
481
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
476
|
-
const more = ['mobil', 'pissarra', 'ajustos', 'ajuda'].includes(r.name);
|
|
482
|
+
const more = ['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
477
483
|
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
478
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('')
|
|
479
485
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
@@ -500,12 +506,12 @@ function render() {
|
|
|
500
506
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
501
507
|
else {
|
|
502
508
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
503
|
-
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>`;
|
|
504
510
|
if (r.name === 'mobil') bindLocal();
|
|
505
|
-
if (r.name === '
|
|
511
|
+
if (r.name === 'mobil' && machine() && !local.engine) loadOllama();
|
|
506
512
|
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
507
513
|
if (r.name === 'memoria' && machine()) loadMemory();
|
|
508
|
-
if (r.name === '
|
|
514
|
+
if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
|
|
509
515
|
if (r.name === 'feines' && machine()) loadJobs();
|
|
510
516
|
hydrateShots();
|
|
511
517
|
}
|
|
@@ -600,7 +606,7 @@ function viewComputers() {
|
|
|
600
606
|
</div>` : '';
|
|
601
607
|
if (detail && info?.device) loadDevices();
|
|
602
608
|
if (ms.length > 1) probeAll();
|
|
603
|
-
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() : ''}`;
|
|
604
610
|
}
|
|
605
611
|
|
|
606
612
|
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
@@ -735,7 +741,7 @@ function renameSheet(i) {
|
|
|
735
741
|
}
|
|
736
742
|
function moreSheet() {
|
|
737
743
|
const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
|
|
738
|
-
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(); }; });
|
|
739
745
|
}
|
|
740
746
|
|
|
741
747
|
/* ---------- Sessions */
|
|
@@ -841,16 +847,16 @@ async function loadActivity() {
|
|
|
841
847
|
let mem = null;
|
|
842
848
|
function viewMemory() {
|
|
843
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>
|
|
844
|
-
<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>
|
|
845
851
|
<div id="memBody">${memHtml()}</div>`;
|
|
846
852
|
}
|
|
847
853
|
function memHtml() {
|
|
848
854
|
if (!mem) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
849
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>`;
|
|
850
|
-
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>`;
|
|
851
857
|
const groups = {};
|
|
852
858
|
for (const f of mem.files) (groups[f.path.includes('/') ? f.path.split('/')[0] : ''] ||= []).push(f);
|
|
853
|
-
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>';
|
|
854
860
|
}
|
|
855
861
|
async function loadMemory() {
|
|
856
862
|
try { mem = await api('/api/memory'); } catch (e) { mem = { error: e.message }; }
|
|
@@ -1041,7 +1047,7 @@ function renderSession() {
|
|
|
1041
1047
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
1042
1048
|
<div class="composer" id="composer"></div>
|
|
1043
1049
|
</section>`;
|
|
1044
|
-
feedEls = new Map(); toolEls = new Map();
|
|
1050
|
+
feedEls = new Map(); toolEls = new Map(); typers.clear();
|
|
1045
1051
|
renderSessionHead();
|
|
1046
1052
|
const feed = $('#feed'), evs = state.events.get(id);
|
|
1047
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>`;
|
|
@@ -1077,8 +1083,50 @@ function autogrow(ta, max = 180) {
|
|
|
1077
1083
|
const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
|
|
1078
1084
|
const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
|
|
1079
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
|
+
};
|
|
1080
1127
|
function putEvent(e, scroll = true) {
|
|
1081
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);
|
|
1082
1130
|
const stick = nearBottom(feed);
|
|
1083
1131
|
if (e.kind === 'tool_result') {
|
|
1084
1132
|
const t = toolEls.get(e.id);
|
|
@@ -1288,7 +1336,6 @@ function focusMain() {
|
|
|
1288
1336
|
const r = state.route.name;
|
|
1289
1337
|
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1290
1338
|
else if (r === 'mobil' && local.engine) $('#lmsg')?.focus({ preventScroll: true });
|
|
1291
|
-
else if (r === 'models') $('#pullIn')?.focus({ preventScroll: true });
|
|
1292
1339
|
}
|
|
1293
1340
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1294
1341
|
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
@@ -1540,8 +1587,9 @@ async function loadOllama(force) {
|
|
|
1540
1587
|
catch (e) { state.ollama = { available: false, models: [], running: [], pulling: [], error: e.message }; }
|
|
1541
1588
|
paintModels();
|
|
1542
1589
|
}
|
|
1543
|
-
function
|
|
1544
|
-
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>`;
|
|
1545
1593
|
}
|
|
1546
1594
|
function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
|
|
1547
1595
|
function paintModels() {
|
|
@@ -1596,14 +1644,14 @@ function onOllama(msg) {
|
|
|
1596
1644
|
state.ollama.pulling = list.filter((x) => x.name !== msg.name);
|
|
1597
1645
|
toast(msg.error ? `${msg.name}: ${msg.error}` : `${msg.name} ja està llest`, 4200);
|
|
1598
1646
|
if (!msg.error) api('/api/info?refresh=1').then((i) => (state.info = i)).catch(() => {});
|
|
1599
|
-
if (state.route.name === '
|
|
1647
|
+
if (state.route.name === 'mobil') loadOllama(true);
|
|
1600
1648
|
return;
|
|
1601
1649
|
}
|
|
1602
1650
|
if (!j) { j = { name: msg.name }; list.push(j); }
|
|
1603
1651
|
Object.assign(j, { status: msg.status, completed: msg.completed, total: msg.total });
|
|
1604
1652
|
const row = $(`[data-pull="${CSS.escape(msg.name)}"]`);
|
|
1605
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)}` : ''}`; }
|
|
1606
|
-
else if (state.route.name === '
|
|
1654
|
+
else if (state.route.name === 'mobil') paintModels();
|
|
1607
1655
|
}
|
|
1608
1656
|
|
|
1609
1657
|
/* ---------- Models per a la pissarra */
|
|
@@ -1670,12 +1718,13 @@ function viewLocal() {
|
|
|
1670
1718
|
const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
1671
1719
|
if (!local.engine) {
|
|
1672
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>
|
|
1673
1722
|
<div class="stack-lg" style="max-width:640px">
|
|
1674
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>'}
|
|
1675
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>
|
|
1676
|
-
<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>
|
|
1677
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>`}
|
|
1678
|
-
</div
|
|
1726
|
+
</div>
|
|
1727
|
+
${ollamaSection()}`;
|
|
1679
1728
|
}
|
|
1680
1729
|
const info = LOCAL_MODELS.find((m) => m.id === local.model);
|
|
1681
1730
|
return `<section class="chat">
|
|
@@ -1774,9 +1823,8 @@ async function panicNow() {
|
|
|
1774
1823
|
if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
|
|
1775
1824
|
try { const r = await api('/api/panic', { method: 'POST' }); toast(`Aturat tot (${r.stopped} agents)`); } catch (e) { toast(e.message, 4500); }
|
|
1776
1825
|
}
|
|
1777
|
-
function
|
|
1778
|
-
|
|
1779
|
-
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>`;
|
|
1780
1828
|
}
|
|
1781
1829
|
function screenHtml() {
|
|
1782
1830
|
if (!screen) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
|
|
@@ -1786,13 +1834,11 @@ function screenHtml() {
|
|
|
1786
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';
|
|
1787
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>`;
|
|
1788
1836
|
};
|
|
1789
|
-
return `<div class="grid2">
|
|
1837
|
+
return `<div class="grid2 one">
|
|
1790
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>
|
|
1791
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>
|
|
1792
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>
|
|
1793
|
-
</div>
|
|
1794
|
-
<div class="stack">
|
|
1795
|
-
<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>
|
|
1796
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>
|
|
1797
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>
|
|
1798
1844
|
</div>
|
|
@@ -1807,7 +1853,7 @@ async function loadScreen() {
|
|
|
1807
1853
|
renderPanic();
|
|
1808
1854
|
}
|
|
1809
1855
|
function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
|
|
1810
|
-
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); } } }
|
|
1811
1857
|
document.addEventListener('click', async (e) => {
|
|
1812
1858
|
const b = e.target.closest('[data-ctl]'); if (!b) return;
|
|
1813
1859
|
b.disabled = true;
|
|
@@ -2033,7 +2079,7 @@ document.addEventListener('submit', (e) => {
|
|
|
2033
2079
|
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
2034
2080
|
let palSel = 0;
|
|
2035
2081
|
function commands() {
|
|
2036
|
-
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]) }));
|
|
2037
2083
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
2038
2084
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
2039
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}`) }));
|
|
@@ -2095,8 +2141,7 @@ const TSTEPS = [
|
|
|
2095
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.' },
|
|
2096
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.' },
|
|
2097
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.' },
|
|
2098
|
-
{ route: '
|
|
2099
|
-
{ 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.' },
|
|
2100
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.' },
|
|
2101
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.' },
|
|
2102
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) => {
|