nio-cat 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nio.js CHANGED
@@ -55,6 +55,31 @@ function lanIps() {
55
55
  .map((i) => i.address);
56
56
  }
57
57
 
58
+ function openUrl(target) {
59
+ try {
60
+ if (process.platform === 'win32') spawn('cmd', ['/c', 'start', '', target], { stdio: 'ignore', detached: true }).unref();
61
+ else spawn(process.platform === 'darwin' ? 'open' : 'xdg-open', [target], { stdio: 'ignore', detached: true }).unref();
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Obre el panell al navegador d'aquest ordinador, ja connectat. En un servidor (SSH, sense pantalla) no ho fa.
69
+ function canOpenBrowser() {
70
+ if (flags['no-open'] || process.env.CI) return false;
71
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return false;
72
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
73
+ return true;
74
+ }
75
+
76
+ function openDashboard() {
77
+ const local = `http://127.0.0.1:${cfg.port}`;
78
+ const link = pairLink(local, local, cfg.token);
79
+ if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell:')} ${local} ${c.dim('(obert al navegador; --no-open per evitar-ho)')}`);
80
+ else console.log(` ${c.dim('Panell:')} ${link}`);
81
+ }
82
+
58
83
  function showPair(publicUrl) {
59
84
  const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
60
85
  const file = writePairPage(link);
@@ -94,7 +119,7 @@ async function start() {
94
119
  const hub = new Hub(cfg);
95
120
  let publicUrl = flags.url || null;
96
121
  const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
97
- const server = createServer({ cfg, hub, getPublicUrl: () => publicUrl });
122
+ const server = createServer({ cfg, hub, getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
98
123
 
99
124
  server.on('error', (e) => {
100
125
  if (e.code === 'EADDRINUSE') console.error(c.warn(`\n El port ${cfg.port} ja el fa servir un altre programa. Tria\'n un altre: nio --port 7718\n`));
@@ -104,6 +129,7 @@ async function start() {
104
129
 
105
130
  server.listen(cfg.port, host, () => {
106
131
  console.log(` ${c.dim('Local:')} http://127.0.0.1:${cfg.port}`);
132
+ openDashboard();
107
133
 
108
134
  if (flags.lan) {
109
135
  const ip = lanIps()[0];
@@ -177,8 +203,13 @@ async function start() {
177
203
  --url <adreça> Una adreça pública teva (Tailscale o similar)
178
204
  --no-tunnel Només des d'aquest ordinador
179
205
  --port <n> Port local (7717 si no dius res)
206
+ --no-open No obris el panell al navegador d'aquest ordinador
180
207
  --open Obre al navegador la pàgina del codi QR
181
208
  nio pair Mostra el codi QR una altra vegada
209
+
210
+ Servidor (VPS): nio --url https://la-teva-adreça --no-open
211
+ Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
212
+
182
213
  nio doctor Llista els agents instal·lats
183
214
  nio reset-token Crea una clau nova i desconnecta tots els mòbils
184
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
package/src/github.js ADDED
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+ // GitHub i git des de l'ordinador: es fa servir el «gh» i el «git» que l'usuari ja té.
3
+ // Cap clau no passa per nio: el token queda on és (gh auth), i res no s'executa amb shell.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execFile } = require('child_process');
7
+ const { which } = require('./proc');
8
+
9
+ function fail(status, message) {
10
+ const e = new Error(message);
11
+ e.status = status;
12
+ return e;
13
+ }
14
+
15
+ function run(cmd, args, { cwd, timeout = 30000 } = {}) {
16
+ const bin = which(cmd);
17
+ if (!bin) return Promise.reject(fail(400, `No trobo «${cmd}» en aquest ordinador`));
18
+ return new Promise((resolve, reject) => {
19
+ execFile(
20
+ bin,
21
+ args,
22
+ { cwd, timeout, maxBuffer: 8e6, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', NO_COLOR: '1', GH_NO_UPDATE_NOTIFIER: '1' } },
23
+ (err, stdout, stderr) => {
24
+ if (err) {
25
+ const msg = String(stderr || err.message || '').trim().split('\n').filter(Boolean).slice(-3).join(' ');
26
+ return reject(fail(400, msg || 'L’ordre ha fallat'));
27
+ }
28
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
29
+ }
30
+ );
31
+ });
32
+ }
33
+
34
+ const OWNER = /^(?![.-])[\w.-]{1,60}$/;
35
+ const REPO = /^(?![.-])[\w.-]{1,60}\/(?![.-])[\w.-]{1,100}$/;
36
+ const BRANCH = /^(?!-)[\w./-]{1,80}$/;
37
+
38
+ async function status() {
39
+ if (!which('gh')) return { available: false, authed: false };
40
+ try {
41
+ await run('gh', ['auth', 'status'], { timeout: 10000 });
42
+ } catch {
43
+ return { available: true, authed: false };
44
+ }
45
+ let user = '';
46
+ try {
47
+ user = (await run('gh', ['api', 'user', '--jq', '.login'], { timeout: 10000 })).stdout.trim();
48
+ } catch {
49
+ /* sense nom */
50
+ }
51
+ return { available: true, authed: true, user };
52
+ }
53
+
54
+ async function repos(owner) {
55
+ const args = ['repo', 'list'];
56
+ if (owner) {
57
+ if (!OWNER.test(owner)) throw fail(400, 'Aquest propietari no és vàlid');
58
+ args.push(owner);
59
+ }
60
+ args.push('--limit', '100', '--json', 'nameWithOwner,description,isPrivate,isFork,updatedAt');
61
+ const { stdout } = await run('gh', args, { timeout: 30000 });
62
+ let list = [];
63
+ try {
64
+ list = JSON.parse(stdout);
65
+ } catch {
66
+ /* buit */
67
+ }
68
+ return list.map((r) => ({
69
+ repo: r.nameWithOwner,
70
+ description: String(r.description || '').slice(0, 160),
71
+ private: !!r.isPrivate,
72
+ fork: !!r.isFork,
73
+ updated: r.updatedAt,
74
+ }));
75
+ }
76
+
77
+ async function clone(cfg, repo) {
78
+ repo = String(repo || '').trim();
79
+ if (!REPO.test(repo)) throw fail(400, 'Aquest repositori no és vàlid');
80
+ const base = cfg.roots[0];
81
+ if (!base) throw fail(400, 'No hi ha cap carpeta compartida on clonar-lo');
82
+ const target = path.join(base, repo.split('/')[1]);
83
+ if (!target.startsWith(base + path.sep)) throw fail(400, 'Aquest nom de carpeta no és vàlid');
84
+ if (fs.existsSync(target)) {
85
+ if (fs.existsSync(path.join(target, '.git'))) return { path: target, existed: true };
86
+ throw fail(409, `Ja hi ha una carpeta «${path.basename(target)}» que no és git`);
87
+ }
88
+ await run('gh', ['repo', 'clone', repo, target], { timeout: 600000 });
89
+ return { path: target, existed: false };
90
+ }
91
+
92
+ async function gitInfo(cwd) {
93
+ try {
94
+ await run('git', ['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 10000 });
95
+ } catch {
96
+ return { repo: false };
97
+ }
98
+ const out = async (args) => {
99
+ try {
100
+ return (await run('git', args, { cwd, timeout: 15000 })).stdout.trim();
101
+ } catch {
102
+ return '';
103
+ }
104
+ };
105
+ const branch = (await out(['rev-parse', '--abbrev-ref', 'HEAD'])) || 'HEAD';
106
+ const raw = await out(['status', '--porcelain=v1']);
107
+ const files = raw ? raw.split('\n').slice(0, 200).map((l) => ({ status: l.slice(0, 2).trim() || '?', path: l.slice(3) })) : [];
108
+ const stat = await out(['diff', '--shortstat', 'HEAD']);
109
+ const remote = (await out(['remote'])).split('\n').filter(Boolean)[0] || '';
110
+ const ahead = remote ? Number(await out(['rev-list', '--count', '@{u}..HEAD'])) || 0 : 0;
111
+ const gh = await status();
112
+ return { repo: true, branch, files, stat, remote, ahead, defaultBranch: branch === 'main' || branch === 'master', gh };
113
+ }
114
+
115
+ async function commit(cwd, { message, branch }) {
116
+ message = String(message || '').trim().slice(0, 500);
117
+ if (!message) throw fail(400, 'Escriu un missatge per al commit');
118
+ if (branch) {
119
+ branch = String(branch).trim();
120
+ if (!BRANCH.test(branch)) throw fail(400, 'Aquest nom de branca no és vàlid');
121
+ await run('git', ['checkout', '-b', branch], { cwd });
122
+ }
123
+ await run('git', ['add', '-A'], { cwd });
124
+ const r = await run('git', ['commit', '-m', message], { cwd, timeout: 60000 });
125
+ return { ok: true, summary: r.stdout.trim().split('\n')[0] || 'Fet' };
126
+ }
127
+
128
+ async function pr(cwd, { title, body }) {
129
+ const info = await gitInfo(cwd);
130
+ if (!info.repo) throw fail(400, 'Aquesta carpeta no és un repositori git');
131
+ if (info.defaultBranch) throw fail(400, 'Ets a la branca principal. Fes el commit en una branca nova per poder crear el PR.');
132
+ if (!info.remote) throw fail(400, 'Aquest repositori no té cap remot (origin)');
133
+ if (!info.gh.authed) throw fail(400, 'Cal iniciar sessió a GitHub amb «gh auth login»');
134
+ await run('git', ['push', '-u', info.remote, 'HEAD'], { cwd, timeout: 120000 });
135
+ title = String(title || '').trim().slice(0, 200);
136
+ const args = ['pr', 'create'];
137
+ if (title) args.push('--title', title, '--body', String(body || '').slice(0, 5000));
138
+ else args.push('--fill');
139
+ const r = await run('gh', args, { cwd, timeout: 60000 });
140
+ const url = (r.stdout.match(/https:\/\/github\.com\/\S+/) || [])[0] || '';
141
+ return { ok: true, url };
142
+ }
143
+
144
+ module.exports = { status, repos, clone, gitInfo, commit, pr };
package/src/pair.js CHANGED
@@ -40,6 +40,10 @@ function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
40
40
  return lines.join('\n');
41
41
  }
42
42
 
43
+ function qrSvg(text) {
44
+ return qr(text).createSvgTag({ cellSize: 6, margin: 4, scalable: true });
45
+ }
46
+
43
47
  function writePairPage(link) {
44
48
  const q = qr(link);
45
49
  const svg = q.createSvgTag({ cellSize: 6, margin: 4, scalable: true });
@@ -58,4 +62,4 @@ code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospa
58
62
  return file;
59
63
  }
60
64
 
61
- module.exports = { pairLink, terminalQr, writePairPage };
65
+ module.exports = { pairLink, terminalQr, writePairPage, qrSvg };
package/src/server.js CHANGED
@@ -9,6 +9,8 @@ const { detectAll } = require('./agents');
9
9
  const ollama = require('./agents/ollama');
10
10
  const { save } = require('./config');
11
11
  const { httpErr } = require('./hub');
12
+ const github = require('./github');
13
+ const { pairLink, qrSvg } = require('./pair');
12
14
 
13
15
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
14
16
  const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
@@ -114,7 +116,7 @@ function insideRoots(cfg, p) {
114
116
  // Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
115
117
  const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
116
118
 
117
- function createServer({ cfg, hub, getPublicUrl }) {
119
+ function createServer({ cfg, hub, getPublicUrl, getApp }) {
118
120
  const streams = new Set();
119
121
  const pulls = new Map(); // descàrregues de models d'Ollama en curs
120
122
 
@@ -201,6 +203,21 @@ function createServer({ cfg, hub, getPublicUrl }) {
201
203
  return { ok: true };
202
204
  }
203
205
 
206
+ if (p === '/api/pair' && method === 'GET') {
207
+ // Enllaç i QR per connectar un altre dispositiu (el mòbil) amb aquest ordinador.
208
+ const pub = getPublicUrl();
209
+ if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
210
+ const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
211
+ return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
212
+ }
213
+ if (p === '/api/github' && method === 'GET') return github.status();
214
+ if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
215
+ if (p === '/api/github/clone' && method === 'POST') {
216
+ const b = await readBody(req);
217
+ const r = await github.clone(cfg, b.repo);
218
+ return r;
219
+ }
220
+
204
221
  if (p === '/api/sessions' && method === 'GET') return hub.list();
205
222
  if (p === '/api/sessions' && method === 'POST') {
206
223
  const b = await readBody(req);
@@ -225,6 +242,14 @@ function createServer({ cfg, hub, getPublicUrl }) {
225
242
  return { ok: true };
226
243
  }
227
244
  }
245
+ if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
246
+ const s = hub.get(r[1]);
247
+ if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
248
+ if (!insideRoots(cfg, s.cwd)) throw httpErr(403, 'Aquesta carpeta no és de les compartides');
249
+ if (!r[2] && method === 'GET') return github.gitInfo(s.cwd);
250
+ if (r[2] === 'commit' && method === 'POST') return github.commit(s.cwd, await readBody(req));
251
+ if (r[2] === 'pr' && method === 'POST') return github.pr(s.cwd, await readBody(req));
252
+ }
228
253
  if ((r = m(/^\/api\/sessions\/([\w-]+)\/message$/)) && method === 'POST') {
229
254
  const b = await readBody(req);
230
255
  return hub.send(r[1], b.text, hookUrl());
package/web/app/app.js CHANGED
@@ -22,6 +22,7 @@ const ICON = {
22
22
  models: '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9"/>',
23
23
  trash: '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
24
24
  download: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
25
+ chev: '<path d="m6 9 6 6 6-6"/>',
25
26
  plus: '<path d="M12 5v14M5 12h14"/>', send: '<path d="M12 19V5M5 12l7-7 7 7"/>',
26
27
  stop: '<rect x="7" y="7" width="10" height="10" rx="1.5" fill="currentColor" stroke="none"/>',
27
28
  more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
@@ -29,7 +30,7 @@ const ICON = {
29
30
  bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
30
31
  copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
31
32
  };
32
- const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Al mòbil' };
33
+ const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
33
34
  const MODES = {
34
35
  llegir: { label: 'Només llegir', short: 'Llegir' },
35
36
  preguntar: { label: 'Demana permís', short: 'Permís' },
@@ -178,8 +179,7 @@ const demo = (() => {
178
179
  { id: 'claude', name: 'Claude Code', available: false, approvals: true, login: 'Instal·la nio al teu ordinador' },
179
180
  { id: 'codex', name: 'Codex', available: false, login: 'Instal·la nio al teu ordinador' },
180
181
  { id: 'grok', name: 'Grok', available: false, beta: true, login: 'Instal·la nio al teu ordinador' },
181
- { id: 'antigravity', name: 'Antigravity', available: false, beta: true, login: 'Instal·la nio al teu ordinador' },
182
- { id: 'ollama', name: 'Ollama', available: true, noModes: true, version: 'simulat', login: 'ollama serve' }
182
+ { id: 'antigravity', name: 'Antigravity', available: false, beta: true, login: 'Instal·la nio al teu ordinador' }
183
183
  ] });
184
184
  async function request(method, path, body) {
185
185
  await sleep(60);
@@ -194,7 +194,10 @@ const demo = (() => {
194
194
  await sleep(700);
195
195
  return { content: JSON.stringify({ reply: 'Model simulat: amb Ollama de debò entendria la petició sencera. T’he fet un esquema amb les paraules clau.', actions: words.length > 1 ? [{ op: 'mindmap', center: words[0], items: words.slice(1) }] : [] }) };
196
196
  }
197
- if (p === '/api/ollama') return { available: true, host: 'simulat', models: omodels, running: [], pulling: [...opulls.values()].map(({ t, ...r }) => r) };
197
+ if (p === '/api/github') return { available: false, authed: false };
198
+ if (/^\/api\/sessions\/[\w-]+\/git$/.test(p)) return { repo: false };
199
+ if (p === '/api/pair') return { link: null };
200
+ if (p === '/api/ollama') return { available: false, host: 'simulat', models: [], running: [], pulling: [] };
198
201
  if (p === '/api/ollama/pull' && method === 'POST') {
199
202
  const name = String(body.name || '').trim(); if (!/^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/.test(name)) throw err(400, 'Aquest nom de model no és vàlid');
200
203
  if (opulls.has(name) || omodels.some((m) => m.name === name)) return { ok: true, already: true };
@@ -362,7 +365,7 @@ const VIEWS = {
362
365
  approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
363
366
  models: { label: 'Models', icon: ICON.models, group: 'Ordinador' },
364
367
  maquina: { label: 'Ordinador', icon: ICON.machine, group: 'Ordinador' },
365
- mobil: { label: 'Al mòbil', icon: ICON.phone, group: 'Eines' },
368
+ mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
366
369
  pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
367
370
  };
368
371
  const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
@@ -390,7 +393,9 @@ function renderChrome() {
390
393
  return `${head}<a href="${HASH[k]}" style="--i:${i}" title="${v.label}" ${k === active ? 'aria-current="page"' : ''}>${svg(v.icon)}<span class="l">${v.label}</span>${k === 'approvals' && n ? `<span class="badge">${n}</span>` : ''}</a>`;
391
394
  }).join('');
392
395
  const m = machine();
393
- $('#sideConn').innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(state.connected ? m.name : state.lastError ? 'Sense connexió' : 'Connectant…')}</span>` : '<i class="dot"></i><span>Cap ordinador connectat</span>';
396
+ const many = store.machines.length > 1, sc = $('#sideConn');
397
+ sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(state.connected ? m.name : state.lastError ? 'Sense connexió' : 'Connectant…')}</span>${many ? svg(ICON.chev) : ''}` : '<i class="dot"></i><span>Cap ordinador connectat</span>';
398
+ sc.classList.toggle('switch', many); many ? (sc.setAttribute('role', 'button'), sc.tabIndex = 0, sc.dataset.act = 'machines', sc.title = 'Canvia d’ordinador') : (sc.removeAttribute('role'), sc.removeAttribute('tabindex'), delete sc.dataset.act, sc.removeAttribute('title'));
394
399
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
395
400
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
396
401
  $('#mRight').innerHTML = r.name === 'session'
@@ -423,7 +428,7 @@ function render() {
423
428
 
424
429
  /* ---------- Connecta */
425
430
  function viewConnect(compact = false) {
426
- const CMD = 'npx https://www.nio.cat/nio.tgz';
431
+ const CMD = 'npx nio-cat';
427
432
  const main = `<div class="panel conn-main">
428
433
  <ol class="stepper">
429
434
  <li><span class="n">1</span><div><b>Engega nio</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
@@ -443,7 +448,7 @@ function viewConnect(compact = false) {
443
448
  <div class="connect-head"><h1>Connecta el teu ordinador</h1><p class="lead">Els agents treballen al teu ordinador, amb els teus comptes i els teus fitxers. Des d’aquí els dones feina, els veus treballar i aproves el que facin.</p></div>
444
449
  <div class="connect-grid">
445
450
  ${main}
446
- <aside class="panel try">${bot('happy')}<h2>Prova-ho sense instal·lar res</h2><p class="muted small">Un ordinador simulat dins del navegador, amb un agent de demostració que llegeix, et demana permís i executa.</p><button class="btn ink block" data-act="demo" type="button">Obre l’ordinador simulat</button><a class="btn ghost block" href="#/mobil">O fes servir un model al mòbil</a></aside>
451
+ <aside class="panel try">${bot('happy')}<h2>Prova-ho sense instal·lar res</h2><p class="muted small">Un ordinador simulat dins del navegador, amb un agent de demostració que llegeix, et demana permís i executa.</p><button class="btn ink block" data-act="demo" type="button">Obre l’ordinador simulat</button><a class="btn ghost block" href="#/mobil">O fes servir el xat local</a></aside>
447
452
  </div>
448
453
  </section>`;
449
454
  }
@@ -457,7 +462,10 @@ function stateChip(s) {
457
462
  }
458
463
  function viewSessions() {
459
464
  const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
460
- const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p></div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`;
465
+ const many = store.machines.length > 1, mach = machine();
466
+ const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
467
+ const hint = mobileHint();
468
+ const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
461
469
  if (!state.connected && !list.length) {
462
470
  return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>Comprova que nio continua engegat a l’ordinador i que el túnel està obert.</p><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>` : ''}</div>`;
463
471
  }
@@ -659,14 +667,14 @@ function modeHelp(agentId, mode) {
659
667
  async function newSessionSheet(preset = {}) {
660
668
  if (!machine()) return go('#/sessions');
661
669
  if (!state.connected) return toast('Encara no hi ha connexió amb l’ordinador');
662
- const agents = state.info?.agents || [];
670
+ const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
663
671
  let projects = [];
664
672
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
665
673
  const first = (isDemo() && agents.find((a) => a.id === 'demo')) || agents.find((a) => a.available && a.id !== 'demo') || agents.find((a) => a.available);
666
674
  const sel = { agent: first?.id || 'demo', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path, mode: 'preguntar', model: '', ...preset };
667
675
  openSheet(`<h2>Nova sessió</h2>
668
676
  <div class="stack"><div class="section-title" style="margin:0">Agent</div><div class="picker" id="agentPick"></div></div>
669
- <div class="stack"><div class="section-title" style="margin:0">Carpeta</div><div class="proj-list" id="projPick"></div></div>
677
+ <div class="stack"><div class="row between"><div class="section-title" style="margin:0">Carpeta</div><button class="chip" type="button" id="ghOpen">Clona de GitHub</button></div><div class="proj-list" id="projPick"></div><div class="stack" id="ghWrap" hidden></div></div>
670
678
  <div class="stack" id="modeWrap"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="modePick" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-mode="${k}">${m.label}</button>`).join('')}</div><p class="muted small" id="modeHelp"></p></div>
671
679
  <div id="modelWrap"></div>
672
680
  <label class="f"><span>Què ha de fer?</span><textarea class="field" id="firstMsg" rows="3" placeholder="Per exemple: troba per què fallen els tests i arregla-ho"></textarea></label>
@@ -692,7 +700,33 @@ async function newSessionSheet(preset = {}) {
692
700
  $('#modelIn', sh).value = sel.model; $('#modelIn', sh).oninput = (e) => (sel.model = e.target.value.trim());
693
701
  }
694
702
  };
703
+ let ghRepos = null;
704
+ const ghPaint = (q = '') => {
705
+ const w = $('#ghList', sh); if (!w) return;
706
+ const qq = q.trim().toLowerCase(), list = (ghRepos || []).filter((r) => !qq || r.repo.toLowerCase().includes(qq) || r.description.toLowerCase().includes(qq)).slice(0, 40);
707
+ w.innerHTML = list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
708
+ };
709
+ const ghOpen = async () => {
710
+ const w = $('#ghWrap', sh); if (!w.hidden) { w.hidden = true; return; }
711
+ w.hidden = false; w.innerHTML = '<p class="muted small">Connectant amb GitHub…</p>';
712
+ let st; try { st = await api('/api/github'); } catch (e) { w.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
713
+ if (!st.available) { w.innerHTML = '<p class="muted small">No trobo l’eina <span class="mono">gh</span> en aquest ordinador. Instal·la-la des de <a href="https://cli.github.com" target="_blank" rel="noopener">cli.github.com</a> i inicia sessió amb <span class="mono">gh auth login</span>.</p>'; return; }
714
+ if (!st.authed) { w.innerHTML = '<p class="muted small">Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.</p>'; return; }
715
+ w.innerHTML = `<input type="search" id="ghQ" placeholder="Cerca entre els teus repositoris${st.user ? ' (' + esc(st.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list" id="ghList"><p class="muted small">Carregant repositoris…</p></div>`;
716
+ $('#ghQ', sh).oninput = (e) => ghPaint(e.target.value);
717
+ try { ghRepos = await api('/api/github/repos'); ghPaint(); } catch (e) { $('#ghList', sh).innerHTML = `<p class="muted small">${esc(e.message)}</p>`; }
718
+ };
719
+ $('#ghOpen', sh).onclick = ghOpen;
720
+ const ghClone = async (repo, btn) => {
721
+ btn.disabled = true; btn.querySelector('.p, div')?.insertAdjacentHTML('beforeend', ' <span class="muted">· clonant…</span>');
722
+ try {
723
+ const r = await api('/api/github/clone', { method: 'POST', body: { repo } });
724
+ if (!projects.some((p) => p.path === r.path)) projects.unshift({ path: r.path, name: r.path.split(/[\\/]/).pop(), root: false, git: true });
725
+ sel.cwd = r.path; $('#ghWrap', sh).hidden = true; paint(); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat');
726
+ } catch (e) { btn.disabled = false; toast(e.message, 6000); }
727
+ };
695
728
  sh.onclick = (e) => {
729
+ const rp = e.target.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
696
730
  if (e.target.closest('[data-close]')) return closeSheet();
697
731
  const ag = e.target.closest('[data-agent-id]');
698
732
  if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); sel.agent = a.id; sel.model = ''; paint(); paintModel(); }
@@ -718,11 +752,81 @@ function modeSheet(s) {
718
752
  }));
719
753
  });
720
754
  }
755
+ /* ---------- Ordinadors i mòbil */
756
+ const isRealMachine = () => { const m = machine(); return !!m && m.url !== DEMO_URL; };
757
+ const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
758
+ function mobileHint() {
759
+ if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
760
+ return `<div class="panel hint"><div class="row between" style="flex-wrap:wrap;gap:12px"><div><b>Vols fer-lo servir al mòbil?</b><p class="muted small" style="margin-top:2px">Escaneja un codi i continues des del telèfon, amb les mateixes sessions.</p></div><div class="row"><button class="btn ghost" type="button" data-act="mobile-hint-off">Ara no</button><button class="btn ink" type="button" data-act="mobile-qr">Mostra el QR</button></div></div></div>`;
761
+ }
762
+ function machinesSheet() {
763
+ const ms = store.machines;
764
+ openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url === DEMO_URL ? 'Dins del navegador' : x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
765
+ <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-act="add-machine">${svg(ICON.plus)}Connecta’n un altre</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
766
+ }
767
+ function addMachineSheet() {
768
+ const cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
769
+ openSheet(`<h2>Connecta un altre ordinador</h2>
770
+ <div class="stack-lg">
771
+ <div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
772
+ <div class="stack"><b>Un servidor (VPS)</b><p class="muted small">Al servidor, amb l’adreça pública amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span> perquè no s’aturi. Copia l’enllaç que mostra i enganxa’l aquí.</p></div>
773
+ <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://…/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
774
+ </div>`);
775
+ }
776
+ let qrTimer = 0;
777
+ function mobileQrSheet() {
778
+ clearTimeout(qrTimer);
779
+ const paint = async (n = 0) => {
780
+ const box = $('#qrBox'); if (!box) return;
781
+ let r; try { r = await api('/api/pair'); } catch (e) { box.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
782
+ if (!$('#qrBox')) return;
783
+ if (!r.link) {
784
+ box.innerHTML = `<p class="muted small">Encara no hi ha cap adreça per al mòbil.${n < 8 ? ' Estic esperant el túnel…' : ''}</p><p class="muted small">Engega nio amb <span class="mono">--lan</span> (mateixa wifi) o instal·la <span class="mono">cloudflared</span> per connectar-t’hi des de qualsevol lloc.</p>`;
785
+ if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
786
+ return;
787
+ }
788
+ box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><button class="btn" type="button" data-copy="${esc(r.link)}">${svg(ICON.copy)}Copia l’enllaç</button>`;
789
+ };
790
+ openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
791
+ paint();
792
+ }
793
+
794
+ /* ---------- Git i GitHub d'una sessió */
795
+ async function gitSheet(id) {
796
+ const s = state.sessions.get(id); if (!s) return;
797
+ openSheet(`<h2>Git i GitHub</h2><p class="muted small" id="gitBody">Mirant els canvis…</p>`);
798
+ const load = async () => {
799
+ let g; try { g = await api(`/api/sessions/${id}/git`); } catch (e) { $('#sheetIn').querySelector('#gitBody, #gitWrap').outerHTML = `<p class="muted small" id="gitBody">${esc(e.message)}</p>`; return; }
800
+ const sh = $('#sheetIn');
801
+ if (!g.repo) { sh.innerHTML = `<div class="grab"></div><h2>Git i GitHub</h2><p class="muted small">Aquesta carpeta no és un repositori git.</p><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`; $('[data-close]', sh).onclick = closeSheet; return; }
802
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/^(\d{8})/, '$1-');
803
+ const files = g.files.slice(0, 12).map((f) => `<div class="row" style="gap:10px"><span class="chip" style="min-width:26px;justify-content:center">${esc(f.status)}</span><span class="mono small" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.path)}</span></div>`).join('');
804
+ const canPr = !g.defaultBranch && g.remote && g.gh?.authed;
805
+ sh.innerHTML = `<div class="grab"></div><h2>Git i GitHub</h2><div class="stack-lg" id="gitWrap">
806
+ <dl class="kv"><dt>Branca</dt><dd class="mono">${esc(g.branch)}</dd>${g.remote ? `<dt>Remot</dt><dd class="mono">${esc(g.remote)}${g.ahead ? ` · ${g.ahead} commit${g.ahead > 1 ? 's' : ''} per pujar` : ''}</dd>` : ''}${g.stat ? `<dt>Canvis</dt><dd>${esc(g.stat)}</dd>` : ''}</dl>
807
+ ${g.files.length ? `<div class="stack"><b>${g.files.length} fitxer${g.files.length > 1 ? 's' : ''} amb canvis</b>${files}${g.files.length > 12 ? `<p class="muted small">I ${g.files.length - 12} més.</p>` : ''}</div>
808
+ <div class="stack"><label class="f"><span>Missatge del commit</span><input type="text" id="gitMsg" maxlength="200" placeholder="Què has canviat?"></label>
809
+ ${g.defaultBranch ? `<label class="f"><span>Branca nova (recomanat, ets a ${esc(g.branch)})</span><input type="text" id="gitBranch" value="nio/${stamp}" maxlength="80" autocapitalize="off" spellcheck="false"></label>` : ''}
810
+ <div class="row"><button class="btn ink" id="gitCommit" type="button">Fes commit</button></div></div>` : '<p class="muted small">No hi ha canvis per desar.</p>'}
811
+ ${canPr ? `<div class="stack"><b>Pull request</b><label class="f"><span>Títol</span><input type="text" id="prTitle" maxlength="200" placeholder="Si ho deixes buit, es fa servir el dels commits"></label><label class="f"><span>Descripció</span><textarea class="field" id="prBody" rows="3"></textarea></label><div class="row"><button class="btn ink" id="gitPr" type="button">Puja i crea el PR</button></div></div>`
812
+ : g.defaultBranch ? '<p class="muted small">Per crear un PR, fes primer el commit en una branca nova.</p>' : !g.remote ? '<p class="muted small">Aquest repositori no té cap remot, així que no es pot crear un PR.</p>' : '<p class="muted small">Per crear PR, inicia sessió a l’ordinador amb <span class="mono">gh auth login</span>.</p>'}
813
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div></div>`;
814
+ $('[data-close]', sh).onclick = closeSheet;
815
+ const busy = (b, on, label) => { b.disabled = on; b.textContent = on ? label : b.dataset.l; };
816
+ const cb = $('#gitCommit', sh);
817
+ if (cb) { cb.dataset.l = cb.textContent; cb.onclick = async () => { const message = $('#gitMsg', sh).value.trim(); if (!message) return toast('Escriu un missatge per al commit'); busy(cb, true, 'Desant…'); try { const r = await api(`/api/sessions/${id}/git/commit`, { method: 'POST', body: { message, branch: $('#gitBranch', sh)?.value.trim() || undefined } }); toast(r.summary || 'Commit fet'); load(); } catch (e) { toast(e.message, 5000); busy(cb, false); } }; }
818
+ const pb = $('#gitPr', sh);
819
+ if (pb) { pb.dataset.l = pb.textContent; pb.onclick = async () => { busy(pb, true, 'Pujant…'); try { const r = await api(`/api/sessions/${id}/git/pr`, { method: 'POST', body: { title: $('#prTitle', sh).value.trim(), body: $('#prBody', sh).value } }); toast('PR creat'); if (r.url) pb.outerHTML = `<a class="btn ink" href="${esc(r.url)}" target="_blank" rel="noopener">Obre el PR</a>`; } catch (e) { toast(e.message, 6000); busy(pb, false); } }; }
820
+ };
821
+ load();
822
+ }
823
+
721
824
  function sessionOptions(id) {
722
825
  const s = state.sessions.get(id); if (!s) return;
723
826
  openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
724
827
  <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[s.agent] || s.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(s.cwd)}</dd><dt>Permisos</dt><dd>${esc(MODES[s.mode]?.label || '')}</dd><dt>Creada</dt><dd>${new Date(s.created).toLocaleString('ca')}</dd></dl>
725
828
  <label class="f"><span>Nom de la sessió</span><input type="text" id="renameIn" value="${esc(s.title)}" maxlength="120"></label>
829
+ ${isRealMachine() ? '<button class="btn" data-act="git" type="button">Git i GitHub…</button>' : ''}
726
830
  <div class="row between"><button class="btn danger" id="delBtn" type="button">Esborra la sessió</button><button class="btn ink" id="renameBtn" type="button">Desa</button></div>`, (sh) => {
727
831
  $('#renameBtn', sh).onclick = async () => { const t = $('#renameIn', sh).value.trim(); const sum = await api(`/api/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toast(e.message)); if (sum) state.sessions.set(id, sum); closeSheet(); renderSessionHead(); };
728
832
  $('#delBtn', sh).onclick = async () => { if (!confirm('S’esborrarà la conversa d’aquesta sessió. Els fitxers del projecte no es toquen.')) return; await api(`/api/sessions/${id}`, { method: 'DELETE' }).catch((e) => toast(e.message)); closeSheet(); go('#/sessions'); };
@@ -741,6 +845,7 @@ function viewMachine() {
741
845
  </div>
742
846
  <div class="stack">
743
847
  <section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
848
+ ${m.url !== DEMO_URL ? '<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>' : ''}
744
849
  <section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url === DEMO_URL ? 'Dins del navegador' : m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
745
850
  <section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url === DEMO_URL ? 'Dins del navegador' : x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
746
851
  <div class="row"><button class="btn" data-act="add-machine" type="button">${svg(ICON.plus)}Connecta’n un altre</button><button class="btn danger" data-act="forget" type="button">Oblida aquest</button></div></div></section>
@@ -835,7 +940,7 @@ window.NioBrains = {
835
940
  list() {
836
941
  const out = [{ id: 'rules', label: 'Ordres ràpides' }];
837
942
  if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}${isDemo() ? ' (simulat)' : ''}` }));
838
- if (local.engine) out.push({ id: 'local', label: `Al mòbil · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
943
+ if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
839
944
  return out;
840
945
  },
841
946
  async ask(id, messages) {
@@ -844,7 +949,7 @@ window.NioBrains = {
844
949
  return (await api('/api/ollama/chat', { method: 'POST', body: { model: id.slice(7), messages, format: 'json' } })).content;
845
950
  }
846
951
  if (id === 'local') {
847
- if (!local.engine) throw new Error('Primer obre un model a «Al mòbil»');
952
+ if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
848
953
  const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
849
954
  return r.choices[0]?.message?.content || '';
850
955
  }
@@ -875,7 +980,7 @@ async function syncServiceWorker() {
875
980
  try { (await navigator.serviceWorker.ready).active?.postMessage({ type: 'nio-cfg', url: m.url, token: m.token }); } catch { /* sense service worker */ }
876
981
  }
877
982
 
878
- /* ---------------------------------------------------------------- model al mòbil (WebLLM) */
983
+ /* ---------------------------------------------------------------- xat local (WebLLM) */
879
984
  const LOCAL_MODELS = [
880
985
  { id: 'Qwen3.5-0.8B-q4f16_1-MLC', name: 'Qwen 3.5 · 0,8B', size: '≈ 0,6 GB', note: 'El més lleuger. Respostes curtes i ràpides.' },
881
986
  { id: 'Llama-3.2-1B-Instruct-q4f16_1-MLC', name: 'Llama 3.2 · 1B', size: '≈ 0,9 GB', note: 'Petit i equilibrat.' },
@@ -888,7 +993,7 @@ const stripThink = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trimSta
888
993
  function viewLocal() {
889
994
  const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
890
995
  if (!local.engine) {
891
- return `<div class="top"><div><h1>Un model dins del mòbil</h1><p>Per quan no tens l’ordinador a mà. Un cop descarregat, funciona sense connexió i res no surt del dispositiu.</p></div></div>
996
+ 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>
892
997
  <div class="stack-lg" style="max-width:640px">
893
998
  ${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>'}
894
999
  <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>
@@ -911,8 +1016,22 @@ function bindLocal() {
911
1016
  const ta = $('#lmsg');
912
1017
  ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; });
913
1018
  ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
914
- $('#lsend').onclick = sendLocal;
1019
+ $('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
1020
+ setLocalBusy(local.busy);
915
1021
  }
1022
+ function setLocalBusy(busy) {
1023
+ const b = $('#lsend'); if (!b) return;
1024
+ b.classList.toggle('stop', busy); b.innerHTML = svg(busy ? ICON.stop : ICON.send);
1025
+ b.setAttribute('aria-label', busy ? 'Para' : 'Envia'); b.title = busy ? 'Para la resposta' : 'Envia';
1026
+ }
1027
+ function stopLocal() { local.stopped = true; try { local.engine?.interruptGenerate(); } catch { /* res */ } }
1028
+ const LOCAL_SYSTEM = [
1029
+ 'Ets en Nio, un assistent que viu dins del mòbil. Parles català, i canvies a la llengua de l’usuari només si ell escriu en una altra.',
1030
+ 'Respon directe, en una a quatre frases curtes. Una idea per frase.',
1031
+ 'No repeteixis mai una frase ni una idea que ja hagis dit. Quan hagis contestat, para.',
1032
+ 'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
1033
+ 'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
1034
+ ].join(' ');
916
1035
  async function loadLocal(id) {
917
1036
  local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
918
1037
  try {
@@ -934,12 +1053,21 @@ async function sendLocal() {
934
1053
  local.messages.push({ role: 'user', content: text });
935
1054
  feed.appendChild(el(`<div class="msg-user">${esc(text)}</div>`));
936
1055
  const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); feed.scrollTop = feed.scrollHeight;
937
- local.busy = true; let acc = '';
1056
+ local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
938
1057
  try {
939
- const chunks = await local.engine.chat.completions.create({ messages: [{ role: 'system', content: 'Ets un assistent útil i concís. Respon en la llengua de l’usuari.' }, ...local.messages.slice(-12)], stream: true, temperature: 0.6 });
940
- for await (const c of chunks) { acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight; }
941
- } catch (e) { acc = acc || `Error: ${e.message}`; out.innerHTML = md(acc); }
942
- out.classList.remove('cursor'); local.messages.push({ role: 'assistant', content: acc }); local.busy = false;
1058
+ const chunks = await local.engine.chat.completions.create({
1059
+ messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
1060
+ temperature: 0.7, top_p: 0.8, frequency_penalty: 0.6, presence_penalty: 0.4, repetition_penalty: 1.15, max_tokens: 700,
1061
+ extra_body: { enable_thinking: false }
1062
+ });
1063
+ for await (const c of chunks) {
1064
+ acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
1065
+ if (local.stopped) break;
1066
+ }
1067
+ } catch (e) { if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc)); }
1068
+ out.classList.remove('cursor');
1069
+ if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
1070
+ local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
943
1071
  }
944
1072
 
945
1073
  /* ---------------------------------------------------------------- accions */
@@ -948,7 +1076,7 @@ document.addEventListener('click', async (e) => {
948
1076
  if (!a) return;
949
1077
  if (a.dataset.copy) { try { await navigator.clipboard.writeText(a.dataset.copy); toast('Copiat'); } catch { toast(a.dataset.copy); } return; }
950
1078
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
951
- if (a.dataset.machine != null) { store.active = Number(a.dataset.machine); state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; connect(); return render(); }
1079
+ if (a.dataset.machine != null) { closeSheet(); store.active = Number(a.dataset.machine); state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; connect(); return render(); }
952
1080
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
953
1081
  switch (a.dataset.act) {
954
1082
  case 'new': return newSessionSheet();
@@ -964,7 +1092,11 @@ document.addEventListener('click', async (e) => {
964
1092
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
965
1093
  case 'push': return enablePush();
966
1094
  case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
967
- case 'add-machine': return openSheet('<h2>Connecta un altre ordinador</h2>' + viewConnect(true));
1095
+ case 'add-machine': return addMachineSheet();
1096
+ case 'machines': return machinesSheet();
1097
+ case 'mobile-qr': return mobileQrSheet();
1098
+ case 'mobile-hint-off': LS.set('nio.mobHint.off', true); return render();
1099
+ case 'git': return gitSheet(state.route.id);
968
1100
  case 'forget': {
969
1101
  if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
970
1102
  const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
@@ -978,6 +1110,7 @@ document.addEventListener('click', async (e) => {
978
1110
  case 'tour': return tourStart();
979
1111
  }
980
1112
  });
1113
+ document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t.classList?.contains('switch') && t.dataset.act) { e.preventDefault(); t.click(); } });
981
1114
  document.addEventListener('submit', (e) => {
982
1115
  if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
983
1116
  if (e.target.id !== 'pairForm') return;
package/web/app/style.css CHANGED
@@ -58,8 +58,10 @@ button { cursor: pointer; }
58
58
  .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); flex: none; }
59
59
  .dot.on { background: var(--moss); box-shadow: 0 0 0 3px color-mix(in srgb, var(--moss) 18%, transparent); }
60
60
  .dot.wait { background: var(--accent); }
61
- .kbd { display: flex; gap: 8px; align-items: center; overflow: hidden; width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); color: var(--fg-muted); font-size: 13px; text-align: left; }
62
- .kbd .ico { width: 16px; height: 16px; }
61
+ .kbd { display: flex; gap: 10px; align-items: center; overflow: hidden; width: 100%; padding: 9px 13px; border: 0; border-radius: 10px; background: none; color: var(--fg-muted); font-size: 14px; font-weight: 500; text-align: left; cursor: pointer; transition: background-color .2s, color .2s; }
62
+ .kbd:hover { color: var(--fg); background: color-mix(in srgb, var(--bg-2) 60%, transparent); }
63
+ .kbd .ico { width: 18px; height: 18px; }
64
+ .kbd kbd { border: 0; background: none; padding: 0; font: 500 12px var(--font); letter-spacing: .02em; color: var(--fg-muted); opacity: .7; }
63
65
  .kbd .l { flex: 1 0 auto; white-space: nowrap; }
64
66
  kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--white); color: var(--fg-muted); }
65
67
  .main { min-width: 0; display: flex; flex-direction: column; }
@@ -298,6 +300,8 @@ dialog.sheet[open] { animation: pop .22s var(--ease); }
298
300
 
299
301
  /* ---------- Pissarra ---------- */
300
302
  body.board-mode .page { padding: 0; max-width: none; }
303
+ body.board-mode { overflow: hidden; overscroll-behavior: none; }
304
+ body.board-mode .view-enter { animation: none; }
301
305
 
302
306
  @media (prefers-reduced-motion: no-preference) {
303
307
  .view-enter { animation: view-in .25s var(--ease); }
@@ -364,3 +368,14 @@ body.board-mode .page { padding: 0; max-width: none; }
364
368
  .tour-foot .btn { height: 36px; padding: 0 14px; font-size: 13px; }
365
369
  .board-root { height: calc(100dvh - 56px - var(--safe-t)) !important; }
366
370
  }
371
+
372
+ /* Ordinadors, QR i git */
373
+ .conn.switch { cursor: pointer; border-radius: 8px; transition: color .2s; }
374
+ .conn.switch:hover { color: var(--fg); }
375
+ .conn.switch .ico { width: 14px; height: 14px; flex: none; }
376
+ .switch-chip { margin-top: 10px; cursor: pointer; }
377
+ .switch-chip .ico { width: 14px; height: 14px; }
378
+ .panel.hint { margin-bottom: 20px; padding: 16px 18px; }
379
+ .qr-box { width: min(260px, 100%); margin: 0 auto; padding: 14px; border-radius: 16px; background: #fffefd; border: 1px solid var(--border); }
380
+ .qr-box svg { display: block; width: 100%; height: auto; }
381
+ #ghWrap input[type="search"] { margin-bottom: 4px; }
package/web/board.css CHANGED
@@ -1,7 +1,7 @@
1
1
  /* Nio · Pissarra */
2
- .board-root { position: relative; height: 100vh; height: 100dvh; --bd-bg: var(--bg); --bd-blue: #2b5bd8; }
3
- .bd { display: flex; height: 100%; min-height: 0; }
4
- .bd-stage { container: stage / inline-size; position: relative; flex: 1; min-width: 0; overflow: hidden; background-color: var(--bg); background-image: radial-gradient(circle, color-mix(in srgb, var(--fg) 16%, transparent) 1px, transparent 1.3px); user-select: none; -webkit-user-select: none; }
2
+ .board-root { position: relative; height: 100vh; height: 100dvh; overflow: hidden; overscroll-behavior: none; --bd-bg: var(--bg); --bd-blue: #2b5bd8; --chat-w: 360px; --bd-ease: cubic-bezier(.22,1,.36,1); }
3
+ .bd { container: stage / inline-size; position: relative; display: block; height: 100%; min-height: 0; overflow: hidden; }
4
+ .bd-stage { position: absolute; inset: 0; overflow: hidden; overscroll-behavior: none; touch-action: none; background-color: var(--bg); background-image: radial-gradient(circle, color-mix(in srgb, var(--fg) 16%, transparent) 1px, transparent 1.3px); user-select: none; -webkit-user-select: none; }
5
5
  .bd-stage.nogrid { background-image: none; }
6
6
  .bd-svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: crosshair; }
7
7
  .bd-stage[data-tool="select"] .bd-svg { cursor: default; }
@@ -28,7 +28,8 @@
28
28
  .bd .ico { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; flex: none; }
29
29
  .bd-bar { position: absolute; z-index: 4; display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); background: color-mix(in srgb, var(--white) 80%, transparent); -webkit-backdrop-filter: blur(16px) saturate(160%); backdrop-filter: blur(16px) saturate(160%); box-shadow: 0 10px 30px -18px color-mix(in srgb, var(--fg) 40%, transparent); }
30
30
  .bd-bar .sep { width: 1px; align-self: stretch; margin: 6px 4px; background: var(--border); }
31
- .bd-tools { top: 14px; left: 50%; transform: translateX(-50%); }
31
+ .bd-tools { top: 14px; left: 50%; transform: translateX(-50%); transition: transform .34s var(--bd-ease); }
32
+ .chat-open .bd-tools { transform: translateX(calc(-50% - var(--chat-w) / 2)); }
32
33
  .bd .tb, .bd .ib { position: relative; display: grid; place-items: center; width: 38px; height: 38px; border: 0; border-radius: 10px; background: none; color: var(--fg); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color .15s, color .15s, transform .12s; }
33
34
  .bd .ib { width: 34px; height: 34px; }
34
35
  .bd .tb:hover, .bd .ib:hover { background: color-mix(in srgb, var(--bg-2) 85%, transparent); }
@@ -39,7 +40,8 @@
39
40
  .bd .pct { min-width: 54px; height: 34px; border: 0; border-radius: 10px; background: none; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; cursor: pointer; }
40
41
  .bd .pct:hover { background: color-mix(in srgb, var(--bg-2) 85%, transparent); }
41
42
  .bd-zoom { left: 14px; bottom: 14px; }
42
- .bd-right { top: 14px; right: 14px; }
43
+ .bd-right { top: 14px; right: 14px; transition: transform .34s var(--bd-ease); }
44
+ .chat-open .bd-right { transform: translateX(calc(-1 * var(--chat-w))); }
43
45
  .bd .chat-t { width: auto; display: flex; gap: 8px; padding: 0 12px 0 9px; font-size: 13px; font-weight: 600; }
44
46
  .bd .chat-t .tbbot { --s: 20px; --bw: .15; }
45
47
  .only-m { display: none !important; }
@@ -84,9 +86,8 @@
84
86
  .bd-help dl div { display: flex; align-items: center; gap: 6px; padding: 6px 0; border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); font-size: 13px; }
85
87
  .bd-help dd { flex: 1; }
86
88
 
87
- .bd-chat { position: relative; z-index: 6; display: flex; flex-direction: column; width: 0; min-height: 0; overflow: hidden; border-left: 1px solid transparent; background: var(--panel); transition: width .38s cubic-bezier(.22,1,.36,1), border-color .38s; }
88
- .chat-open .bd-chat { width: 360px; border-left-color: var(--border); }
89
- .bd-chat > * { width: 360px; flex: none; }
89
+ .bd-chat { position: absolute; z-index: 6; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: var(--chat-w); min-height: 0; overflow: hidden; border-left: 1px solid var(--border); background: var(--panel); transform: translateX(100%); visibility: hidden; transition: transform .34s var(--bd-ease), visibility 0s linear .34s; }
90
+ .chat-open .bd-chat { transform: none; visibility: visible; transition: transform .34s var(--bd-ease), visibility 0s; }
90
91
  .bd-chat header { display: flex; justify-content: space-between; align-items: center; padding: 14px 14px 12px 16px; border-bottom: 1px solid var(--border); }
91
92
  .bd-chat .who { display: flex; align-items: center; gap: 10px; }
92
93
  .bd-chat .hbot { --s: 30px; --bw: .14; }
@@ -114,11 +115,10 @@
114
115
 
115
116
  @media (prefers-reduced-motion: reduce) { .bd *, .bd *::before { animation: none !important; transition: none !important; } }
116
117
 
117
- @media (max-width: 1100px) { .chat-open .bd-chat, .bd-chat > * { width: 320px; } }
118
+ @media (max-width: 1100px) { .board-root { --chat-w: 320px; } }
118
119
  @media (max-width: 820px) {
119
120
  .board-root { height: calc(100dvh - 56px - env(safe-area-inset-top)); }
120
- .bd { display: block; position: relative; }
121
- .bd-stage { position: absolute; inset: 0; }
121
+ .chat-open .bd-tools, .chat-open .bd-right { transform: none; }
122
122
  .bd-tools { top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; }
123
123
  .bd-tools::-webkit-scrollbar { display: none; }
124
124
  .bd .tb { width: 40px; height: 40px; flex: none; }
@@ -132,15 +132,18 @@
132
132
  .bd-style { display: none; }
133
133
  .style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + env(safe-area-inset-bottom)); width: auto; grid-template-columns: 1fr 1fr; }
134
134
  .bd-help .cols { grid-template-columns: 1fr; }
135
- .bd-chat { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; width: auto !important; height: min(72%, 560px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .42s cubic-bezier(.22,1,.36,1); box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
136
- .chat-open .bd-chat { transform: none; }
137
- .bd-chat > * { width: auto !important; }
135
+ .bd-chat { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(72%, 560px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
136
+ .chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
138
137
  .bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
139
138
  .bd-empty { top: 42%; }
140
139
  }
141
140
 
142
141
  /* El llenç s'adapta a la seva amplada real (tauleta, xat obert, mòbil) */
143
142
  @container stage (max-width: 720px) {
143
+ .bd-chat { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(72%, 560px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
144
+ .chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
145
+ .bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
146
+ .chat-open .bd-tools, .chat-open .bd-right { transform: none; }
144
147
  .bd-tools { top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; justify-content: flex-start; }
145
148
  .bd-tools::-webkit-scrollbar { display: none; }
146
149
  .bd .tb { flex: none; }
package/web/board.js CHANGED
@@ -3,7 +3,7 @@
3
3
  'use strict';
4
4
  const KEY = 'nio.board.v1';
5
5
  const NS = 'http://www.w3.org/2000/svg';
6
- const GAP = 6;
6
+ const GAP = 3;
7
7
  const HAND = '"Caveat", "Segoe Print", "Comic Sans MS", cursive';
8
8
  const CLEAN = '"Manrope", system-ui, sans-serif';
9
9
  const STROKES = [['#272224', 'Negre'], ['#e2382b', 'Vermell'], ['#2f7d4f', 'Verd'], ['#2b5bd8', 'Blau'], ['#e07b1f', 'Taronja'], ['#8a5cf6', 'Lila']];
@@ -86,6 +86,7 @@ function wrap(text, fs, maxW) {
86
86
  return out;
87
87
  }
88
88
  const fsOf = el => SIZES[el.fs] || el.fsPx || SIZES.m;
89
+ const textW = el => Math.max(30, el.type === 'rect' ? el.w - 28 : el.type === 'ellipse' ? el.w * .7 - 8 : el.w * .5 - 6);
89
90
  function fitText(el) {
90
91
  const fs = fsOf(el), lines = String(el.text || '').split('\n');
91
92
  el.w = Math.max(20, ...lines.map(l => measure(l, fs))) + 4;
@@ -100,6 +101,10 @@ function rline(x1, y1, x2, y2, r, amt) {
100
101
  const mx = (x1 + x2) / 2 + j() * 1.4, my = (y1 + y2) / 2 + j() * 1.4;
101
102
  return `M${f1(x1 + j())} ${f1(y1 + j())}Q${f1(mx)} ${f1(my)} ${f1(x2 + j())} ${f1(y2 + j())}`;
102
103
  }
104
+ function rlineX(x1, y1, x2, y2, r, amt) {
105
+ const len = Math.hypot(x2 - x1, y2 - y1), o = Math.min(amt, len * .03 + .3), j = () => (r() - .5) * 2 * o;
106
+ return `M${f1(x1)} ${f1(y1)}Q${f1((x1 + x2) / 2 + j() * 1.4)} ${f1((y1 + y2) / 2 + j() * 1.4)} ${f1(x2)} ${f1(y2)}`;
107
+ }
103
108
  function smooth(pts, closed) {
104
109
  if (pts.length < 2) return '';
105
110
  const p = closed ? [pts[pts.length - 1], ...pts, pts[0], pts[1]] : [pts[0], ...pts, pts[pts.length - 1]];
@@ -110,33 +115,43 @@ function smooth(pts, closed) {
110
115
  }
111
116
  return d;
112
117
  }
118
+ function polyPath(pts, r, bow) {
119
+ let d = `M${f1(pts[0][0])} ${f1(pts[0][1])}`;
120
+ for (let i = 0; i < pts.length; i++) {
121
+ const p = pts[i], q = pts[(i + 1) % pts.length], o = Math.min(bow, Math.hypot(q[0] - p[0], q[1] - p[1]) * .02 + .3);
122
+ d += `Q${f1((p[0] + q[0]) / 2 + (r() - .5) * 2 * o)} ${f1((p[1] + q[1]) / 2 + (r() - .5) * 2 * o)} ${f1(q[0])} ${f1(q[1])}`;
123
+ }
124
+ return d + 'Z';
125
+ }
113
126
  function shapePaths(el) {
114
- const { x, y, w, h } = el, rough = B.opts.rough, r = rng(el.seed), amt = 1.2 + el.sw * .5;
127
+ const { x, y, w, h } = el, rough = B.opts.rough, r = rng(el.seed), amt = .8 + el.sw * .3, bow = 1 + el.sw * .3;
128
+ const jit = (pts, k) => pts.map(p => [p[0] + (r() - .5) * 2 * amt * k, p[1] + (r() - .5) * 2 * amt * k]);
115
129
  if (el.type === 'rect') {
116
- const clean = `M${x + 8} ${y}H${x + w - 8}Q${x + w} ${y} ${x + w} ${y + 8}V${y + h - 8}Q${x + w} ${y + h} ${x + w - 8} ${y + h}H${x + 8}Q${x} ${y + h} ${x} ${y + h - 8}V${y + 8}Q${x} ${y} ${x + 8} ${y}Z`;
117
- if (!rough) return { fill: clean, stroke: [clean] };
118
- const pass = () => [rline(x, y, x + w, y, r, amt), rline(x + w, y, x + w, y + h, r, amt), rline(x + w, y + h, x, y + h, r, amt), rline(x, y + h, x, y, r, amt)].join('');
119
- return { fill: clean, stroke: [pass(), pass()] };
130
+ const c = Math.min(8, w / 2, h / 2);
131
+ if (!rough) {
132
+ const clean = `M${x + c} ${y}H${x + w - c}Q${x + w} ${y} ${x + w} ${y + c}V${y + h - c}Q${x + w} ${y + h} ${x + w - c} ${y + h}H${x + c}Q${x} ${y + h} ${x} ${y + h - c}V${y + c}Q${x} ${y} ${x + c} ${y}Z`;
133
+ return { fill: clean, stroke: [clean] };
134
+ }
135
+ const pts = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], p0 = polyPath(jit(pts, .5), r, bow);
136
+ return { fill: p0, stroke: [p0, polyPath(jit(pts, 1.4), r, bow)] };
120
137
  }
121
138
  if (el.type === 'diamond') {
122
- const t = [x + w / 2, y], ri = [x + w, y + h / 2], b = [x + w / 2, y + h], l = [x, y + h / 2];
123
- const clean = `M${t[0]} ${t[1]}L${ri[0]} ${ri[1]}L${b[0]} ${b[1]}L${l[0]} ${l[1]}Z`;
139
+ const pts = [[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]];
140
+ const clean = `M${pts.map(p => p.join(' ')).join('L')}Z`;
124
141
  if (!rough) return { fill: clean, stroke: [clean] };
125
- const pass = () => [rline(...t, ...ri, r, amt), rline(...ri, ...b, r, amt), rline(...b, ...l, r, amt), rline(...l, ...t, r, amt)].join('');
126
- return { fill: clean, stroke: [pass(), pass()] };
142
+ const p0 = polyPath(jit(pts, .5), r, bow);
143
+ return { fill: p0, stroke: [p0, polyPath(jit(pts, 1.4), r, bow)] };
127
144
  }
128
145
  const cx = x + w / 2, cy = y + h / 2, rx = Math.max(1, w / 2), ry = Math.max(1, h / 2);
129
146
  const clean = `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${rx * 2} 0a${rx} ${ry} 0 1 0 ${-rx * 2} 0Z`;
130
147
  if (!rough) return { fill: clean, stroke: [clean] };
131
- const pass = k => {
132
- const n = 22, a0 = r() * Math.PI * 2, pts = [];
133
- for (let i = 0; i <= n; i++) {
134
- const a = a0 + (i / n) * Math.PI * 2 * (1.04 + k * .02), q = 1 + (r() - .5) * .035;
135
- pts.push([cx + Math.cos(a) * rx * q, cy + Math.sin(a) * ry * q]);
136
- }
137
- return smooth(pts, false);
148
+ const ring = k => {
149
+ const n = 28, pts = [];
150
+ for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2, q = 1 + (r() - .5) * .02 * k; pts.push([cx + Math.cos(a) * rx * q, cy + Math.sin(a) * ry * q]); }
151
+ return smooth(pts, true) + 'Z';
138
152
  };
139
- return { fill: clean, stroke: [pass(0), pass(1)] };
153
+ const p0 = ring(1);
154
+ return { fill: p0, stroke: [p0, ring(2.4)] };
140
155
  }
141
156
 
142
157
  /* ---------- Geometria ---------- */
@@ -194,16 +209,16 @@ function elSVG(el, exp) {
194
209
  const p = shapePaths(el);
195
210
  if (el.fill && el.fill !== 'none') s += `<path d="${p.fill}" fill="${el.fill}"/>`;
196
211
  s += p.stroke.map((d, i) => `<path d="${d}" ${st}${i ? ' opacity=".7"' : ''}/>`).join('');
197
- if (el.text) s += labelSVG(el.text, el.x + el.w / 2, el.y + el.h / 2, fsOf(el), el.stroke, Math.max(30, (el.type === 'rect' ? el.w : el.w * .72) - 16));
212
+ if (el.text) s += labelSVG(el.text, el.x + el.w / 2, el.y + el.h / 2, fsOf(el), el.stroke, textW(el));
198
213
  if (!exp) s += `<path class="hit" d="${p.fill}"/>`;
199
214
  } else if (isLinear(el)) {
200
215
  const [a, b] = ends(el), r = rng(el.seed), amt = 1 + el.sw * .4;
201
- const body = B.opts.rough ? rline(a[0], a[1], b[0], b[1], r, amt) + rline(a[0], a[1], b[0], b[1], r, amt * .7) : `M${f1(a[0])} ${f1(a[1])}L${f1(b[0])} ${f1(b[1])}`;
216
+ const body = B.opts.rough ? rlineX(a[0], a[1], b[0], b[1], r, amt) + rlineX(a[0], a[1], b[0], b[1], r, amt * .7) : `M${f1(a[0])} ${f1(a[1])}L${f1(b[0])} ${f1(b[1])}`;
202
217
  let head = '';
203
218
  if (el.type === 'arrow') {
204
219
  const ang = Math.atan2(b[1] - a[1], b[0] - a[0]), L = Math.min(16 + el.sw * 2, Math.hypot(b[0] - a[0], b[1] - a[1]) * .5);
205
220
  const h1 = [b[0] - L * Math.cos(ang - .45), b[1] - L * Math.sin(ang - .45)], h2 = [b[0] - L * Math.cos(ang + .45), b[1] - L * Math.sin(ang + .45)];
206
- head = B.opts.rough ? rline(h1[0], h1[1], b[0], b[1], r, 1) + rline(h2[0], h2[1], b[0], b[1], r, 1) : `M${f1(h1[0])} ${f1(h1[1])}L${f1(b[0])} ${f1(b[1])}L${f1(h2[0])} ${f1(h2[1])}`;
221
+ head = `M${f1(h1[0])} ${f1(h1[1])}L${f1(b[0])} ${f1(b[1])}L${f1(h2[0])} ${f1(h2[1])}`;
207
222
  }
208
223
  s += `<path d="${body}" ${st}/>`;
209
224
  if (head) s += `<path d="${head}" fill="none" stroke="${el.stroke}" stroke-width="${el.sw}" stroke-linecap="round" stroke-linejoin="round"/>`;
@@ -341,7 +356,7 @@ function placeEditor() {
341
356
  if (el.type === 'text') { x = el.x; y = el.y; w = Math.max(el.w, 120); ta.style.textAlign = 'left'; }
342
357
  else {
343
358
  const b = isLinear(el) ? (() => { const [a, c] = ends(el); return { x: (a[0] + c[0]) / 2 - 110, y: (a[1] + c[1]) / 2, w: 220, h: 0 }; })() : bbox(el);
344
- w = isLinear(el) ? 220 : Math.max(60, (el.type === 'rect' ? el.w : el.w * .72) - 16);
359
+ w = isLinear(el) ? 220 : Math.max(60, textW(el));
345
360
  x = b.x + (b.w - w) / 2; y = b.y + b.h / 2; ta.style.textAlign = 'center';
346
361
  }
347
362
  ta.style.width = w * z + 'px';
@@ -358,6 +373,7 @@ function endEdit() {
358
373
  if (el.text !== v) { if (!el._new) record(); el.text = v; }
359
374
  delete el._new;
360
375
  if (el.type === 'text') { if (!v) { B.els = B.els.filter(e => e !== el); sel.delete(el.id); } else fitText(el); }
376
+ else growToFit(el);
361
377
  full(); save();
362
378
  }
363
379
 
@@ -569,7 +585,7 @@ function paste() {
569
585
  }
570
586
  function setStyle(k, v) {
571
587
  B.style[k] = v;
572
- if (sel.size) { record(); sel.forEach(id => { const el = byId(id); if (!el) return; if (k === 'fill' && !isShape(el)) return; el[k] = v; if (k === 'fs') { el.fsPx = null; if (el.type === 'text') fitText(el); } }); full(); }
588
+ if (sel.size) { record(); sel.forEach(id => { const el = byId(id); if (!el) return; if (k === 'fill' && !isShape(el)) return; el[k] = v; if (k === 'fs') { el.fsPx = null; if (el.type === 'text') fitText(el); else growToFit(el); } }); full(); }
573
589
  save(); panel();
574
590
  }
575
591
  function order(dir) {
@@ -608,13 +624,19 @@ function importJSON(file) {
608
624
  }
609
625
 
610
626
  /* ---------- Diagrames ---------- */
611
- function nodeSize(label, type) {
612
- const fs = SIZES[B.style.fs] || 24, lines = wrap(label, fs, 240);
613
- const tw = Math.max(...lines.map(l => measure(l, fs)), 40);
614
- let w = Math.max(150, tw + 44), h = Math.max(72, lines.length * fs * 1.25 + 32);
615
- if (type === 'diamond') { w = Math.max(190, tw * 1.5 + 40); h = Math.max(120, h * 1.5); }
616
- if (type === 'ellipse') { w = Math.max(150, tw * 1.25 + 40); h = Math.max(76, h * 1.1); }
617
- return [w, h];
627
+ function nodeSize(label, type, fsKey) {
628
+ const fs = SIZES[fsKey || B.style.fs] || 24, lines = wrap(label, fs, type === 'diamond' ? 150 : type === 'ellipse' ? 200 : 240);
629
+ const tw = Math.max(...lines.map(l => measure(l, fs)), 40), th = lines.length * fs * 1.25;
630
+ if (type === 'rect') return [Math.max(150, Math.ceil(tw + 56)), Math.max(72, Math.ceil(th + 40))];
631
+ if (type === 'ellipse') return [Math.max(160, Math.ceil((tw + 8) / .7 + 8)), Math.max(80, Math.ceil(th * 1.5 + 24))];
632
+ return [Math.max(190, Math.ceil((tw + 6) / .5 + 8)), Math.max(120, Math.ceil(th * 2 + 32))];
633
+ }
634
+ // Fa créixer la figura (des del centre) si el text no hi cap.
635
+ function growToFit(el) {
636
+ if (!isShape(el) || !el.text) return;
637
+ const [w, h] = nodeSize(el.text, el.type, el.fs), [cx, cy] = center(el);
638
+ const nw = Math.max(el.w, w), nh = Math.max(el.h, h);
639
+ if (nw !== el.w || nh !== el.h) { el.w = nw; el.h = nh; el.x = cx - nw / 2; el.y = cy - nh / 2; }
618
640
  }
619
641
  function guessType(label) {
620
642
  const n = norm(label);
@@ -631,7 +653,7 @@ function findEl(q) {
631
653
  }
632
654
  function addNode(label, type, x, y, extra = {}) {
633
655
  type = type || guessType(label);
634
- const [w, h] = nodeSize(label, type);
656
+ const [w, h] = nodeSize(label, type, B.style.fs);
635
657
  const el = mk(type, Object.assign({ x: x - w / 2, y: y - h / 2, w, h, text: label.trim() }, extra));
636
658
  if (type === 'diamond' && el.fill === 'none') el.fill = '#fbf1bf';
637
659
  B.els.push(el); return el;
@@ -1000,9 +1022,12 @@ function onKeyUp(e) { if (e.key === ' ') { spaceDown = false; R && R.stage.class
1000
1022
  function openChat(open, focus = true, persist = true) {
1001
1023
  if (persist) { B.opts.chat = open; save(); }
1002
1024
  if (!R) return;
1025
+ const wasOpen = R.root.classList.contains('chat-open');
1003
1026
  R.root.classList.toggle('chat-open', open);
1004
- R.root.querySelector('[data-bd=chat]').setAttribute('aria-pressed', open);
1005
- if (open && focus) setTimeout(() => R.input.focus(), 60);
1027
+ R.chat.inert = !open;
1028
+ const t = R.root.querySelector('[data-bd=chat]'); t.setAttribute('aria-pressed', open);
1029
+ if (open && focus) setTimeout(() => R.input.focus(), reduce ? 0 : 280);
1030
+ if (!open && wasOpen && R.chat.contains(document.activeElement)) t.focus({ preventScroll: true });
1006
1031
  }
1007
1032
 
1008
1033
  /* ---------- Muntatge ---------- */
@@ -1128,7 +1153,7 @@ function mount(root) {
1128
1153
  R = { root, stage: root.querySelector('#bdStage'), svg: root.querySelector('#bdSvg'), world: root.querySelector('#bdWorld'), els: root.querySelector('#bdEls'), sel: root.querySelector('#bdSel'),
1129
1154
  editor: root.querySelector('#bdEdit'), empty: root.querySelector('#bdEmpty'), tools: root.querySelector('#bdTools'), style: root.querySelector('#bdStyle'),
1130
1155
  zoomPct: root.querySelector('#bdPct'), help: root.querySelector('#bdHelp'), msgs: root.querySelector('#bdMsgs'), input: root.querySelector('#bdInput'),
1131
- chatBot: root.querySelector('.bd-chat .hbot') };
1156
+ chatBot: root.querySelector('.bd-chat .hbot'), chat: root.querySelector('#bdChat') };
1132
1157
  nodes.clear();
1133
1158
  const first = !B.els.length && B.view.x === 0 && B.view.y === 0;
1134
1159
  if (first) { const r = R.svg.getBoundingClientRect(); B.view = { x: r.width / 2, y: r.height / 2, z: 1 }; }
@@ -1138,7 +1163,7 @@ function mount(root) {
1138
1163
  on(root, 'click', onClick);
1139
1164
  on(R.editor, 'input', placeEditor); on(R.editor, 'blur', () => setTimeout(endEdit, 0));
1140
1165
  on(R.root.querySelector('#bdForm'), 'submit', e => { e.preventDefault(); send(); });
1141
- on(R.input, 'keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') R.input.blur(); });
1166
+ on(R.input, 'keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } if (e.key === 'Escape') { e.stopPropagation(); openChat(false); } });
1142
1167
  on(R.input, 'input', () => { R.input.style.height = ''; R.input.style.height = Math.min(140, R.input.scrollHeight) + 'px'; });
1143
1168
  on(R.root.querySelector('#bdBrain'), 'change', (e) => { B.opts.brain = e.target.value; save(); say(B.opts.brain === 'rules' ? 'Faig servir les ordres ràpides. Escriu <b>ajuda</b> per veure-les.' : `Ara t’entendré amb <b>${esc(e.target.selectedOptions[0].textContent)}</b>. Demana’m el diagrama com vulguis.`); });
1144
1169
  on(R.root.querySelector('#bdBrain'), 'focus', drawBrains);
package/web/index.html CHANGED
@@ -108,7 +108,7 @@ main strong { font-weight: 600; }
108
108
  <section aria-labelledby="com">
109
109
  <h2 id="com">Com funciona</h2>
110
110
  <p>Obre el terminal a la carpeta dels teus projectes i executa:</p>
111
- <div class="cmd"><code id="cmd">npx https://www.nio.cat/nio.tgz</code><button class="copy" type="button" data-copy="npx https://www.nio.cat/nio.tgz" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
111
+ <div class="cmd"><code id="cmd">npx nio-cat</code><button class="copy" type="button" data-copy="npx nio-cat" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
112
112
  <p>Escaneja el codi QR que apareix i ja estàs connectat. A casa, per la wifi; amb <code>cloudflared</code>, des de qualsevol lloc. Només cal tenir <a href="https://nodejs.org">Node.js</a>, i cap dada no passa per servidors nostres.</p>
113
113
  </section>
114
114