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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
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') delete last.open;
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: 'event', session: s.id, event: last });
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 since = Math.max(0, ...evs.keys());
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) if (!evs.has(e.seq)) handle({ type: 'event', session: r.id, event: e });
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', pantalla: '#/pantalla', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
443
- const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', 'pantalla', 'models', 'session']);
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 === 'pantalla' ? viewScreen() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
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 === 'models' && machine()) loadOllama();
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 === 'pantalla' && machine()) loadScreen();
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="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>
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)}" 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>';
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 viewModels() {
1543
- return `<div class="top"><div><h1>Models</h1><p>Models d’Ollama que funcionen al teu ordinador, sense internet i sense comptes.</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>`;
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 === 'models') loadOllama(true);
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 === 'models') paintModels();
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 viewScreen() {
1777
- const head = `<div class="top"><div><h1>Pantalla</h1><p>Els agents poden fer servir el navegador (agent-browser) i les apps de l’escriptori (agent-desktop). Mirar passa sol; clicar, escriure o enviar sempre et demana permís, amb una captura.</p></div><button class="btn ink" data-act="screen-now" type="button" ${controlOn() ? '' : 'disabled'}>${svg(ICON.screen)}Veure pantalla ara</button></div>`;
1778
- return `${head}<div id="screenBody">${screenHtml()}</div>`;
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; if (state.route.name === 'pantalla') { const box = $('#screenBody'); box.innerHTML = screenHtml(); hydrateShots(box); } } }
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).filter(([k]) => !(k === 'models' && mq.matches)).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
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: 'models', skipIf: () => !machine() || mq.matches, target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
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-v20';
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) => {