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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.8.1",
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
@@ -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: 'event', session: s.id, event: last });
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', pantalla: '#/pantalla', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
444
- 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']);
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 === '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>`;
504
510
  if (r.name === 'mobil') bindLocal();
505
- if (r.name === 'models' && machine()) loadOllama();
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 === 'pantalla' && machine()) loadScreen();
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="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>
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)}" 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>';
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 viewModels() {
1544
- 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>`;
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 === 'models') loadOllama(true);
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 === 'models') paintModels();
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 viewScreen() {
1778
- 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>`;
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; 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); } } }
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).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]) }));
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: '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.' },
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-v21';
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) => {