nio-cat 0.1.1 → 0.3.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/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,13 +30,12 @@ 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' },
36
37
  tot: { label: 'Via lliure', short: 'Lliure' }
37
38
  };
38
- const DEMO_URL = 'demo:navegador';
39
39
 
40
40
  /* ---------------------------------------------------------------- estat */
41
41
  const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set() };
@@ -44,13 +44,12 @@ const LS = {
44
44
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
45
45
  };
46
46
  const store = {
47
- get machines() { return LS.get('nio.machines', []); },
47
+ get machines() { return LS.get('nio.machines', []).filter((m) => m.url !== 'demo:navegador'); },
48
48
  set machines(v) { LS.set('nio.machines', v); },
49
49
  get active() { return Number(LS.get('nio.active', 0)) || 0; },
50
50
  set active(i) { LS.set('nio.active', i); }
51
51
  };
52
52
  const machine = () => store.machines[store.active] || store.machines[0] || null;
53
- const isDemo = () => machine()?.url === DEMO_URL;
54
53
 
55
54
  /* ---------------------------------------------------------------- utilitats */
56
55
  function toast(msg, ms = 2800) {
@@ -101,172 +100,23 @@ function prettyInput(input) {
101
100
  return JSON.stringify(input, null, 2);
102
101
  }
103
102
 
104
- /* ---------------------------------------------------------------- ordinador de prova (dins del navegador) */
105
- const demo = (() => {
106
- const listeners = new Set();
107
- const sessions = new Map(), approvals = new Map(), notices = [];
108
- const runs = new Map();
109
- const omodels = [{ name: 'qwen3:4b', size: 2.5e9, params: '4.0B', quant: 'Q4_K_M' }], opulls = new Map();
110
- const emit = (msg) => listeners.forEach((f) => f(JSON.parse(JSON.stringify(msg))));
111
- const err = (status, message) => Object.assign(new Error(message), { status });
112
- const summary = (s) => {
113
- const last = [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user');
114
- return { id: s.id, agent: s.agent, title: s.title, cwd: s.cwd, mode: s.mode, model: s.model, state: s.state, created: s.created, updated: s.updated, cost: 0, last: last ? last.text.slice(0, 160) : '', pending: [...approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length };
115
- };
116
- const touch = (s) => { s.updated = Date.now(); emit({ type: 'session', session: summary(s) }); };
117
- const push = (s, kind, data = {}) => {
118
- const lastEv = s.events[s.events.length - 1];
119
- if (lastEv && lastEv.open) delete lastEv.open;
120
- const ev = { seq: ++s.seq, t: Date.now(), kind, ...data }; s.events.push(ev);
121
- emit({ type: 'event', session: s.id, event: ev }); touch(s); return ev;
122
- };
123
- const chunk = (s, text) => {
124
- const last = s.events[s.events.length - 1];
125
- if (last && last.kind === 'text' && last.open) { last.text += text; emit({ type: 'event', session: s.id, event: last }); return; }
126
- const ev = push(s, 'text', { text }); ev.open = true;
127
- };
128
- const pub = (a) => { const { resolve, ...r } = a; return r; };
129
- function ask(s, tool, input) {
130
- const a = { id: rid(), session: s.id, agent: s.agent, title: s.title, tool, input, state: 'pending', created: Date.now() };
131
- const p = new Promise((res) => (a.resolve = res));
132
- approvals.set(a.id, a); s.state = 'waiting';
133
- push(s, 'approval', { approval: a.id, tool, input, state: 'pending' });
134
- emit({ type: 'approval', approval: pub(a) });
135
- notices.unshift({ id: rid(4), t: Date.now(), title: 'Demo et demana permís', body: `${tool}: ${input.command || ''}`, session: s.id });
136
- return p;
137
- }
138
- function decide(id, decision) {
139
- const a = approvals.get(id); if (!a || a.state !== 'pending') return null;
140
- a.state = decision === 'allow' ? 'allowed' : 'denied';
141
- const s = sessions.get(a.session);
142
- if (s) { const ev = s.events.find((e) => e.kind === 'approval' && e.approval === id); if (ev) { ev.state = a.state; emit({ type: 'event', session: s.id, event: ev }); } s.state = runs.has(s.id) ? 'running' : 'idle'; touch(s); }
143
- emit({ type: 'approval', approval: pub(a) }); a.resolve(a.state === 'allowed' ? 'allow' : 'deny');
144
- return pub(a);
145
- }
146
- async function run(s, prompt) {
147
- let stopped = false; runs.set(s.id, { stop: () => (stopped = true) });
148
- if (s.agent === 'ollama') {
149
- try {
150
- for (const w of `(Resposta simulada de ${s.model || 'qwen3:4b'}.) Amb Ollama de debò, aquí veuries la resposta del model que funciona al teu ordinador, paraula per paraula.`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(45); }
151
- push(s, 'result', { ms: 1800 });
152
- } catch { push(s, 'info', { text: 'Has aturat l’agent' }); }
153
- runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 }); return;
154
- }
155
- try {
156
- for (const w of `Entesos. Faig una ullada al projecte per a «${prompt.slice(0, 80)}».`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(55); }
157
- push(s, 'tool', { id: 't1', name: 'Read', input: { file_path: 'README.md' } }); await sleep(450);
158
- push(s, 'tool_result', { id: 't1', name: 'Read', output: '# Projecte de prova\nAixò és un README d’exemple.' });
159
- if (stopped) throw 0;
160
- const input = { command: 'npm test' };
161
- if (s.mode === 'llegir') push(s, 'text', { text: 'Treballo en mode només lectura, així que no executo res. El següent pas seria `npm test`.' });
162
- else {
163
- const ok = s.mode === 'tot' ? true : (await ask(s, 'Bash', input)) === 'allow';
164
- if (stopped) throw 0;
165
- push(s, 'tool', { id: 't2', name: 'Bash', input }); await sleep(380);
166
- if (ok) { push(s, 'tool_result', { id: 't2', name: 'Bash', output: '✓ 12 tests passen' }); push(s, 'text', { text: 'Fet. Tots els tests passen.' }); }
167
- else { push(s, 'tool_result', { id: 't2', name: 'Bash', output: 'Rebutjat des de nio', error: true }); push(s, 'text', { text: 'D’acord, no l’executo. Què vols que faci?' }); }
168
- }
169
- push(s, 'result', { cost: 0, ms: 2400 });
170
- runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 });
171
- } catch {
172
- runs.delete(s.id); s.state = 'idle'; push(s, 'info', { text: 'Has aturat l’agent' }); push(s, 'status', { state: 'idle', code: 130 });
173
- }
174
- }
175
- const info = () => ({ name: 'nio', version: 'prova', host: 'Ordinador de prova', platform: 'navegador', user: 'tu', publicUrl: DEMO_URL, roots: ['~/projectes'], vapidKey: null,
176
- agents: [
177
- { id: 'demo', name: 'Demo', available: true, version: 'simulat', approvals: true, login: 'No cal instal·lar res' },
178
- { id: 'claude', name: 'Claude Code', available: false, approvals: true, login: 'Instal·la nio al teu ordinador' },
179
- { id: 'codex', name: 'Codex', available: false, login: 'Instal·la nio al teu ordinador' },
180
- { 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' }
183
- ] });
184
- async function request(method, path, body) {
185
- await sleep(60);
186
- const url = new URL(path, 'http://x'), p = url.pathname; let m;
187
- if (p === '/api/info') return info();
188
- if (p === '/api/projects') return [{ path: '~/projectes', name: 'projectes', root: true }, { path: '~/projectes/botiga', name: 'botiga', git: true }, { path: '~/projectes/web', name: 'web', git: true }];
189
- if (p === '/api/ollama/models') return omodels;
190
- if (p === '/api/ollama/chat') {
191
- // Sense model de veritat: proposa un mapa amb les paraules clau de la petició.
192
- const ask = String(body.messages?.at(-1)?.content || '').split('Petició de l’usuari:').pop().trim();
193
- const words = [...new Set(ask.replace(/[^\p{L}\p{N}\s-]/gu, ' ').split(/\s+/).filter((w) => w.length > 3))].slice(0, 7);
194
- await sleep(700);
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
- }
197
- if (p === '/api/ollama') return { available: true, host: 'simulat', models: omodels, running: [], pulling: [...opulls.values()].map(({ t, ...r }) => r) };
198
- if (p === '/api/ollama/pull' && method === 'POST') {
199
- 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
- if (opulls.has(name) || omodels.some((m) => m.name === name)) return { ok: true, already: true };
201
- const total = 1.2e9 + Math.random() * 3e9, job = { name, status: 'pulling manifest', completed: 0, total };
202
- job.t = setInterval(() => {
203
- job.completed = Math.min(total, job.completed + total / 18); job.status = job.completed < total ? 'pulling' : 'success';
204
- emit({ type: 'ollama', name, status: job.status, completed: job.completed, total });
205
- if (job.completed >= total) { clearInterval(job.t); opulls.delete(name); omodels.push({ name, size: total, params: '', quant: 'Q4_K_M' }); emit({ type: 'ollama', name, done: true }); }
206
- }, 260);
207
- opulls.set(name, job); return { ok: true };
208
- }
209
- if ((m = p.match(/^\/api\/ollama\/pull\/(.+)$/)) && method === 'DELETE') { const j = opulls.get(decodeURIComponent(m[1])); if (j) { clearInterval(j.t); opulls.delete(j.name); emit({ type: 'ollama', name: j.name, done: true, error: 'Descàrrega cancel·lada' }); } return { ok: !!j }; }
210
- if ((m = p.match(/^\/api\/ollama\/models\/(.+)$/)) && method === 'DELETE') { const n = decodeURIComponent(m[1]); const i = omodels.findIndex((x) => x.name === n); if (i >= 0) omodels.splice(i, 1); return { ok: true }; }
211
- if (p === '/api/sessions' && method === 'GET') return [...sessions.values()].sort((a, b) => b.updated - a.updated).map(summary);
212
- if (p === '/api/sessions' && method === 'POST') {
213
- if (body.agent !== 'demo' && body.agent !== 'ollama') throw err(400, 'A l’ordinador de prova només hi ha els agents Demo i Ollama');
214
- const s = { id: rid(), agent: body.agent, cwd: body.cwd || '~/projectes', title: (body.text || '').replace(/\s+/g, ' ').slice(0, 60), mode: body.mode || 'preguntar', model: body.model || '', state: 'idle', created: Date.now(), updated: Date.now(), seq: 0, events: [] };
215
- sessions.set(s.id, s); touch(s);
216
- if (body.text) send(s.id, body.text);
217
- return summary(s);
218
- }
219
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
220
- const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
221
- if (method === 'GET') return { session: summary(s), events: s.events.filter((e) => e.seq > Number(url.searchParams.get('since') || 0)) };
222
- if (method === 'PATCH') { if (body.mode) s.mode = body.mode; if (typeof body.title === 'string') s.title = body.title.slice(0, 120); touch(s); return summary(s); }
223
- if (method === 'DELETE') { runs.get(s.id)?.stop(); sessions.delete(s.id); emit({ type: 'removed', session: s.id }); return { ok: true }; }
224
- }
225
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/message$/))) return send(m[1], body.text);
226
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/stop$/))) { const r = runs.get(m[1]); r?.stop(); return { stopped: !!r }; }
227
- if (p === '/api/approvals') return [...approvals.values()].filter((a) => a.state === 'pending').map(pub);
228
- if ((m = p.match(/^\/api\/approvals\/([\w-]+)$/))) { const a = decide(m[1], body.decision); if (!a) throw err(404, 'Aquesta petició ja s’ha resolt'); return a; }
229
- if (p === '/api/notices') return notices.slice(0, 5);
230
- if (p.startsWith('/api/push')) throw err(400, 'Els avisos només funcionen amb un ordinador de veritat');
231
- throw err(404, 'Aquesta ruta no existeix');
232
- }
233
- function send(sid, text) {
234
- const s = sessions.get(sid); if (!s) throw err(404, 'Aquesta sessió no existeix');
235
- if (runs.has(sid)) throw err(409, 'L’agent encara està treballant. Espera que acabi o atura’l.');
236
- text = String(text || '').trim(); if (!text) throw err(400, 'El missatge és buit');
237
- if (!s.title) s.title = text.slice(0, 60);
238
- push(s, 'user', { text }); s.state = 'running'; push(s, 'status', { state: 'running' });
239
- run(s, text); return summary(s);
240
- }
241
- function subscribe(fn) {
242
- listeners.add(fn);
243
- fn({ type: 'hello', sessions: [...sessions.values()].map(summary), approvals: [...approvals.values()].filter((a) => a.state === 'pending').map(pub) });
244
- return () => listeners.delete(fn);
245
- }
246
- return { request, subscribe };
247
- })();
248
-
249
103
  /* ---------------------------------------------------------------- API */
250
104
  async function api(path, opts = {}) {
251
105
  const m = machine();
252
106
  if (!m) throw new Error('No hi ha cap ordinador connectat');
253
107
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
254
- if (m.url === DEMO_URL) return demo.request(opts.method || 'GET', path, body || {});
255
108
  const res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined });
256
109
  const data = await res.json().catch(() => ({}));
257
110
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
111
+ if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
258
112
  return data;
259
113
  }
260
114
 
261
- let streamCtrl = null, unsubDemo = null, retry = 0;
115
+ let streamCtrl = null, retry = 0;
262
116
  async function connect() {
263
- streamCtrl?.abort(); unsubDemo?.(); unsubDemo = null;
117
+ streamCtrl?.abort();
264
118
  const m = machine();
265
119
  if (!m) { state.connected = false; return render(); }
266
- if (m.url === DEMO_URL) {
267
- state.info = await api('/api/info'); state.connected = true; state.lastError = null;
268
- unsubDemo = demo.subscribe(handle); render(); brainsForBoard(); return;
269
- }
270
120
  const ctrl = new AbortController(); streamCtrl = ctrl;
271
121
  try {
272
122
  state.info = await api('/api/info');
@@ -343,12 +193,11 @@ function parsePair(text) {
343
193
  }
344
194
  function addMachine({ url, token, name }) {
345
195
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
346
- if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || (url === DEMO_URL ? 'Ordinador de prova' : new URL(url).hostname) }); i = ms.length - 1; }
196
+ if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
347
197
  store.machines = ms; store.active = i;
348
198
  state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
349
199
  connect();
350
200
  }
351
- function tryDemo() { addMachine({ url: DEMO_URL, token: 'demo', name: 'Ordinador de prova' }); toast('Connectat a l’ordinador de prova'); go('#/sessions'); }
352
201
  function checkPairHash() {
353
202
  const pair = parsePair(location.hash); if (!pair) return false;
354
203
  history.replaceState(null, '', location.pathname + '#/sessions');
@@ -362,9 +211,14 @@ const VIEWS = {
362
211
  approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
363
212
  models: { label: 'Models', icon: ICON.models, group: 'Ordinador' },
364
213
  maquina: { label: 'Ordinador', icon: ICON.machine, group: 'Ordinador' },
365
- mobil: { label: 'Al mòbil', icon: ICON.phone, group: 'Eines' },
214
+ mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
366
215
  pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
367
216
  };
217
+ const NOMACHINE = {
218
+ approvals: { title: 'Aprovacions', lead: 'Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.' },
219
+ models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
220
+ maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
221
+ };
368
222
  const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
369
223
  function parseRoute() {
370
224
  const h = location.hash.replace(/^#\/?/, '');
@@ -390,7 +244,9 @@ function renderChrome() {
390
244
  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
245
  }).join('');
392
246
  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>';
247
+ const many = store.machines.length > 1, sc = $('#sideConn');
248
+ 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>';
249
+ 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
250
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
395
251
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
396
252
  $('#mRight').innerHTML = r.name === 'session'
@@ -412,7 +268,7 @@ function render() {
412
268
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
413
269
  else {
414
270
  const html = r.name === 'mobil' ? viewLocal() : null;
415
- view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : !machine() && r.name !== 'maquina' ? viewConnect() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
271
+ view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'sessions' ? (machine() ? viewSessions() : viewConnect()) : !machine() ? viewNoMachine(NOMACHINE[r.name]) : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
416
272
  if (r.name === 'mobil') bindLocal();
417
273
  if (r.name === 'models' && machine()) loadOllama();
418
274
  }
@@ -422,29 +278,46 @@ function render() {
422
278
  }
423
279
 
424
280
  /* ---------- Connecta */
425
- function viewConnect(compact = false) {
426
- const CMD = 'npx https://www.nio.cat/nio.tgz';
427
- const main = `<div class="panel conn-main">
428
- <ol class="stepper">
429
- <li><span class="n">1</span><div><b>Engega nio</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
430
- <li><span class="n">2</span><div><b>Escaneja el QR</b><p>Amb la càmera del mòbil. S’obrirà nio connectat.</p></div></li>
431
- <li><span class="n">3</span><div><b>Dona-li feina</b><p>Tria un agent i una carpeta, i explica què vols.</p></div></li>
432
- </ol>
433
- <div class="cmd big"><code>${CMD}</code><button class="btn sm" data-copy="${CMD}" type="button">${svg(ICON.copy)}Copia</button></div>
434
- <div class="reqs"><span class="chip">Node.js 18 o més nou</span><span class="chip ghost">cloudflared · opcional</span></div>
435
- <p class="muted small">Sense <span class="mono">cloudflared</span> et connectes per la wifi de casa. Amb ell, des de qualsevol lloc (a macOS: <span class="mono">brew install cloudflared</span>).</p>
436
- <details class="paste" ${compact ? 'open' : ''}><summary>Ja tens l’enllaç del terminal?</summary>
437
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
438
- <p class="muted small">El trobaràs sota el codi QR. Va bé si fas servir nio des de la pantalla d’inici.</p>
439
- </details>
440
- </div>`;
441
- if (compact) return main;
281
+ // La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
282
+ const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
283
+ function viewNoMachine(what) {
284
+ return `<div class="top"><div><h1>${esc(what.title)}</h1><p>${esc(what.lead)}</p></div></div>
285
+ <div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
286
+ }
287
+ function pasteForm(open) {
288
+ return `<details class="paste" ${open ? 'open' : ''}><summary>Ja tens l’enllaç de connexió?</summary>
289
+ <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
290
+ <p class="muted small">Surt al terminal, sota el codi QR.</p></details>`;
291
+ }
292
+ function viewConnect() {
293
+ const CMD = 'npx nio-cat', 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>`;
294
+ if (isPhone()) {
295
+ return `<section class="connect">
296
+ <div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen al teu ordinador, amb els teus comptes i els teus fitxers. Des del mòbil els dones feina i aproves el que facin.</p></div>
297
+ <div class="panel conn-main">
298
+ <ol class="stepper">
299
+ <li><span class="n">1</span><div><b>Engega nio a l’ordinador</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
300
+ <li><span class="n">2</span><div><b>Escaneja el QR</b><p>Amb la càmera del mòbil. S’obrirà nio connectat.</p></div></li>
301
+ </ol>
302
+ ${cmd(CMD)}
303
+ <p class="muted small">Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span>. Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>
304
+ ${pasteForm(false)}
305
+ </div>
306
+ </section>`;
307
+ }
442
308
  return `<section class="connect">
443
- <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
- <div class="connect-grid">
445
- ${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>
309
+ <div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen a l’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>
310
+ <div class="choice-grid conn-main">
311
+ <section class="panel choice"><header><h2>Aquest ordinador</h2></header><div class="body stack">
312
+ <p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
313
+ <p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
314
+ <section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
315
+ <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia l’enllaç que mostra i enganxa’l aquí.</p>
316
+ ${pasteForm(true)}
317
+ <p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
318
+ <p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
447
319
  </div>
320
+ <p class="muted small" style="margin-top:14px">Després, al mòbil, tria l’ordinador i escaneja el codi QR que mostra el panell.</p>
448
321
  </section>`;
449
322
  }
450
323
 
@@ -457,7 +330,10 @@ function stateChip(s) {
457
330
  }
458
331
  function viewSessions() {
459
332
  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>`;
333
+ const many = store.machines.length > 1, mach = machine();
334
+ 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>` : '';
335
+ const hint = mobileHint();
336
+ 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
337
  if (!state.connected && !list.length) {
462
338
  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
339
  }
@@ -640,8 +516,8 @@ function renderComposer() {
640
516
  }
641
517
 
642
518
  /* ---------- Fulls */
643
- function openSheet(html, onMount) {
644
- const d = $('#sheet'); $('#sheetIn').innerHTML = `<div class="grab"></div>${html}`;
519
+ function openSheet(html, onMount, cls) {
520
+ const d = $('#sheet'); d.classList.toggle('wiz', cls === 'wiz'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls === 'wiz' ? '' : `<div class="grab"></div>${html}`;
645
521
  if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
646
522
  onMount?.($('#sheetIn'));
647
523
  }
@@ -656,59 +532,122 @@ function modeHelp(agentId, mode) {
656
532
  if (agentId === 'codex') return 'Pot editar dins la carpeta, sense accés a internet. Codex no pot fer preguntes en aquest mode.';
657
533
  return 'Aquest agent encara no pot demanar permís: les accions que el necessitarien es rebutgen.';
658
534
  }
535
+ // Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
659
536
  async function newSessionSheet(preset = {}) {
660
537
  if (!machine()) return go('#/sessions');
661
538
  if (!state.connected) return toast('Encara no hi ha connexió amb l’ordinador');
662
- const agents = state.info?.agents || [];
539
+ const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
663
540
  let projects = [];
664
541
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
665
- const first = (isDemo() && agents.find((a) => a.id === 'demo')) || agents.find((a) => a.available && a.id !== 'demo') || agents.find((a) => a.available);
666
- const sel = { agent: first?.id || 'demo', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path, mode: 'preguntar', model: '', ...preset };
667
- openSheet(`<h2>Nova sessió</h2>
668
- <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>
670
- <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
- <div id="modelWrap"></div>
672
- <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>
673
- <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="startBtn" type="button">Comença</button></div>`, (sh) => {
674
- const paint = () => {
675
- $('#agentPick', sh).innerHTML = agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('');
676
- $('#projPick', sh).innerHTML = projects.map((p) => `<button type="button" class="proj" data-path="${esc(p.path)}" aria-pressed="${p.path === sel.cwd}"><span class="ic">${p.git ? 'git' : '·'}</span><span class="grow"><div>${esc(p.name)}</div><div class="p">${esc(p.path)}</div></span></button>`).join('') || '<p class="muted small">No hi ha carpetes compartides.</p>';
677
- const ag = agents.find((a) => a.id === sel.agent);
678
- $('#modeWrap', sh).hidden = !!ag?.noModes;
679
- $$('#modePick button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.mode === sel.mode));
680
- $('#modeHelp', sh).textContent = modeHelp(sel.agent, sel.mode);
542
+ const first = agents.find((a) => a.available);
543
+ const sel = { agent: first?.id || '', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: 'preguntar', model: '', text: '', ...preset };
544
+ const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
545
+ let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
546
+ const agentOf = () => agents.find((a) => a.id === sel.agent);
547
+
548
+ openSheet('', (sh) => {
549
+ const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
550
+ const head = () => {
551
+ const last = step === STEPS.length - 1;
552
+ $('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div>
553
+ <div class="wiz-dots">${STEPS.map((n, i) => `<button type="button" class="wiz-dot ${i === step ? 'on' : i < step ? 'done' : ''}" data-step="${i}" ${i < step ? '' : 'disabled'} aria-label="${i + 1}. ${n}"></button>`).join('')}</div>
554
+ <p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
555
+ $('#wizFoot', sh).innerHTML = `<button class="btn ghost" type="button" ${step ? 'data-back' : 'data-close'}>${step ? 'Enrere' : 'Cancel·la'}</button><button class="btn ink" type="button" ${last ? 'data-start' : 'data-next'}>${last ? 'Comença' : 'Continua'}</button>`;
681
556
  };
682
- const paintModel = async () => {
683
- const w = $('#modelWrap', sh);
684
- if (sel.agent === 'ollama') {
685
- const models = await api('/api/ollama/models').catch(() => []);
686
- if (!sel.model) sel.model = models[0]?.name || '';
687
- w.innerHTML = `<label class="f"><span>Model</span><select id="modelSel">${models.map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || '<option value="">Cap model (ollama pull qwen3:4b)</option>'}</select></label>`;
688
- $('#modelSel', sh).onchange = (e) => (sel.model = e.target.value);
689
- } else if (sel.agent === 'demo') w.innerHTML = '';
690
- else {
691
- w.innerHTML = '<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>';
692
- $('#modelIn', sh).value = sel.model; $('#modelIn', sh).oninput = (e) => (sel.model = e.target.value.trim());
693
- }
557
+ const draw = () => {
558
+ if (!$('#wizBody', sh)) sh.innerHTML = '<div class="wiz-head" id="wizHead"></div><div class="wiz-body" id="wizBody"></div><div class="wiz-foot" id="wizFoot"></div>';
559
+ head(); body();
560
+ $('#wizBody', sh).scrollTop = 0;
561
+ };
562
+ const after = () => {
563
+ if (step === 1 && !fsd) fsGo(sel.cwd);
564
+ if (step === 2 && sel.agent === 'ollama' && models == null) api('/api/ollama/models').catch(() => []).then((m) => { models = m; if (!sel.model) sel.model = m[0]?.name || ''; if (step === 2) body(); });
565
+ if (step === 3 && !isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 60);
566
+ };
567
+
568
+ /* --- carpeta: la carpeta oberta és la triada */
569
+ const fsGo = async (dir) => {
570
+ try { fsd = await api(`/api/fs?path=${encodeURIComponent(dir || '')}`); sel.cwd = fsd.path; gh = null; }
571
+ catch (e) { toast(e.message, 4200); if (!fsd) fsd = { path: sel.cwd, parent: null, home: '', dirs: [] }; }
572
+ if (step === 1) body();
573
+ };
574
+ const ghList = () => {
575
+ const q = (gh.q || '').trim().toLowerCase(), list = (gh.repos || []).filter((r) => !q || r.repo.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 40);
576
+ return 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>';
577
+ };
578
+ const ghStart = async () => {
579
+ gh = { msg: 'Connectant amb GitHub…', repos: null, q: '' }; body();
580
+ let st; try { st = await api('/api/github'); } catch (e) { gh.msg = esc(e.message); return body(); }
581
+ if (!st.available) gh.msg = '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>.';
582
+ else if (!st.authed) gh.msg = 'Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.';
583
+ else { gh.user = st.user; gh.msg = 'Carregant repositoris…'; body(); try { gh.repos = await api('/api/github/repos'); gh.msg = ''; } catch (e) { gh.msg = esc(e.message); } }
584
+ if (gh && step === 1) body();
585
+ };
586
+ const ghClone = async (repo, btn) => {
587
+ btn.disabled = true; btn.querySelector('.p, div')?.insertAdjacentHTML('beforeend', ' <span class="muted">· clonant…</span>');
588
+ try { const r = await api('/api/github/clone', { method: 'POST', body: { repo } }); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat'); await fsGo(r.path); }
589
+ catch (e) { btn.disabled = false; toast(e.message, 6000); }
590
+ };
591
+
592
+ const VIEWS_ = [
593
+ () => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
594
+ () => {
595
+ const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
596
+ const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
597
+ return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
598
+ <form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
599
+ <div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
600
+ <div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
601
+ ${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
602
+ : !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
603
+ <div class="proj-list fs-list">${fsd.dirs.map((d) => `<button type="button" class="proj" data-fs="${esc(d.path)}"><span class="ic">›</span><span class="grow"><div ${d.hidden ? 'class="muted"' : ''}>${esc(d.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`}`;
604
+ },
605
+ () => {
606
+ const ag = agentOf();
607
+ const modelHtml = sel.agent === 'ollama'
608
+ ? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
609
+ : `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
610
+ return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
611
+ },
612
+ () => `<p class="wiz-q">Què ha de fer?</p>
613
+ <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
614
+ <label class="f"><span>Explica-li la tasca</span><textarea class="field" id="firstMsg" rows="5" placeholder="Per exemple: troba per què fallen els tests i arregla-ho">${esc(sel.text)}</textarea></label>`
615
+ ];
616
+
617
+ const start = async () => {
618
+ if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
619
+ const btn = $('[data-start]', sh); btn.disabled = true;
620
+ try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
621
+ catch (e) { toast(e.message, 4200); btn.disabled = false; }
694
622
  };
695
623
  sh.onclick = (e) => {
696
- if (e.target.closest('[data-close]')) return closeSheet();
697
- const ag = e.target.closest('[data-agent-id]');
698
- 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(); }
699
- const pj = e.target.closest('[data-path]'); if (pj) { sel.cwd = pj.dataset.path; paint(); }
700
- const mo = e.target.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; paint(); }
624
+ const t = e.target;
625
+ if (t.closest('[data-close]')) return closeSheet();
626
+ if (t.closest('[data-back]')) { step = Math.max(0, step - 1); return draw(); }
627
+ if (t.closest('[data-next]')) {
628
+ if (step === 0 && !sel.agent) return toast('Tria un agent');
629
+ if (step === 1 && !sel.cwd) return toast('Tria una carpeta');
630
+ step++; return draw();
631
+ }
632
+ if (t.closest('[data-start]')) return start();
633
+ const st = t.closest('[data-step]'); if (st && !st.disabled) { step = +st.dataset.step; return draw(); }
634
+ const ag = t.closest('[data-agent-id]');
635
+ 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); if (a.id !== sel.agent) { sel.agent = a.id; sel.model = ''; models = null; } step = 1; return draw(); }
636
+ const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
637
+ if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
638
+ const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
639
+ const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
701
640
  };
702
- paint(); paintModel();
703
- $('#startBtn', sh).onclick = async () => {
704
- const text = $('#firstMsg', sh).value.trim();
705
- if (!text) { $('#firstMsg', sh).focus(); return toast('Explica a l’agent què ha de fer'); }
706
- $('#startBtn', sh).disabled = true;
707
- try { const s = await api('/api/sessions', { method: 'POST', body: { ...sel, text } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
708
- catch (e) { toast(e.message, 4200); $('#startBtn', sh).disabled = false; }
641
+ sh.onsubmit = (e) => { e.preventDefault(); if (e.target.id === 'fsForm') fsGo($('#fsIn', sh).value.trim()); };
642
+ sh.oninput = (e) => {
643
+ const id = e.target.id;
644
+ if (id === 'firstMsg') sel.text = e.target.value;
645
+ else if (id === 'modelIn') sel.model = e.target.value.trim();
646
+ else if (id === 'ghQ' && gh) { gh.q = e.target.value; $('#ghList', sh).innerHTML = ghList(); }
709
647
  };
710
- if (!isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 80);
711
- });
648
+ sh.onchange = (e) => { if (e.target.id === 'modelSel') sel.model = e.target.value; };
649
+ draw();
650
+ }, 'wiz');
712
651
  }
713
652
  function modeSheet(s) {
714
653
  openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
@@ -718,11 +657,81 @@ function modeSheet(s) {
718
657
  }));
719
658
  });
720
659
  }
660
+ /* ---------- Ordinadors i mòbil */
661
+ const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
662
+ function mobileHint() {
663
+ if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
664
+ 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>`;
665
+ }
666
+ function machinesSheet() {
667
+ const ms = store.machines;
668
+ 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)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
669
+ <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; });
670
+ }
671
+ function addMachineSheet() {
672
+ 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>`;
673
+ openSheet(`<h2>Connecta un altre ordinador</h2>
674
+ <div class="stack-lg">
675
+ <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>
676
+ <div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
677
+ <div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça 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>. Copia l’enllaç que mostra i enganxa’l aquí.</p></div>
678
+ <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>
679
+ </div>`);
680
+ }
681
+ let qrTimer = 0;
682
+ function mobileQrSheet() {
683
+ clearTimeout(qrTimer);
684
+ const paint = async (n = 0) => {
685
+ const box = $('#qrBox'); if (!box) return;
686
+ let r; try { r = await api('/api/pair'); } catch (e) { box.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
687
+ if (!$('#qrBox')) return;
688
+ if (!r.link) {
689
+ 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>`;
690
+ if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
691
+ return;
692
+ }
693
+ 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>`;
694
+ };
695
+ 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; });
696
+ paint();
697
+ }
698
+
699
+ /* ---------- Git i GitHub d'una sessió */
700
+ async function gitSheet(id) {
701
+ const s = state.sessions.get(id); if (!s) return;
702
+ openSheet(`<h2>Git i GitHub</h2><p class="muted small" id="gitBody">Mirant els canvis…</p>`);
703
+ const load = async () => {
704
+ 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; }
705
+ const sh = $('#sheetIn');
706
+ 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; }
707
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/^(\d{8})/, '$1-');
708
+ 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('');
709
+ const canPr = !g.defaultBranch && g.remote && g.gh?.authed;
710
+ sh.innerHTML = `<div class="grab"></div><h2>Git i GitHub</h2><div class="stack-lg" id="gitWrap">
711
+ <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>
712
+ ${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>
713
+ <div class="stack"><label class="f"><span>Missatge del commit</span><input type="text" id="gitMsg" maxlength="200" placeholder="Què has canviat?"></label>
714
+ ${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>` : ''}
715
+ <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>'}
716
+ ${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>`
717
+ : 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>'}
718
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div></div>`;
719
+ $('[data-close]', sh).onclick = closeSheet;
720
+ const busy = (b, on, label) => { b.disabled = on; b.textContent = on ? label : b.dataset.l; };
721
+ const cb = $('#gitCommit', sh);
722
+ 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); } }; }
723
+ const pb = $('#gitPr', sh);
724
+ 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); } }; }
725
+ };
726
+ load();
727
+ }
728
+
721
729
  function sessionOptions(id) {
722
730
  const s = state.sessions.get(id); if (!s) return;
723
731
  openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
724
732
  <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
733
  <label class="f"><span>Nom de la sessió</span><input type="text" id="renameIn" value="${esc(s.title)}" maxlength="120"></label>
734
+ <button class="btn" data-act="git" type="button">Git i GitHub…</button>
726
735
  <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
736
  $('#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
737
  $('#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'); };
@@ -732,8 +741,7 @@ function sessionOptions(id) {
732
741
  /* ---------- Ordinador */
733
742
  function viewMachine() {
734
743
  const m = machine(), info = state.info, ms = store.machines;
735
- if (!m) return viewConnect();
736
- return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button></div>
744
+ return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><div class="row"><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div></div>
737
745
  <div class="grid2">
738
746
  <div class="stack">
739
747
  <section class="panel"><header><h2>Agents</h2></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Sense connexió.</p>'}</div></section>
@@ -741,8 +749,9 @@ function viewMachine() {
741
749
  </div>
742
750
  <div class="stack">
743
751
  <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>
744
- <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
- <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('')}
752
+ ${'<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>'}
753
+ <section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(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>
754
+ <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)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
746
755
  <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>
747
756
  </div>
748
757
  </div>`;
@@ -834,8 +843,8 @@ function onOllama(msg) {
834
843
  window.NioBrains = {
835
844
  list() {
836
845
  const out = [{ id: 'rules', label: 'Ordres ràpides' }];
837
- 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}` });
846
+ if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
847
+ if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
839
848
  return out;
840
849
  },
841
850
  async ask(id, messages) {
@@ -844,7 +853,7 @@ window.NioBrains = {
844
853
  return (await api('/api/ollama/chat', { method: 'POST', body: { model: id.slice(7), messages, format: 'json' } })).content;
845
854
  }
846
855
  if (id === 'local') {
847
- if (!local.engine) throw new Error('Primer obre un model a «Al mòbil»');
856
+ if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
848
857
  const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
849
858
  return r.choices[0]?.message?.content || '';
850
859
  }
@@ -861,7 +870,6 @@ async function brainsForBoard() {
861
870
  function b64uToU8(s) { const b = atob((s + '='.repeat((4 - (s.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from(b, (c) => c.charCodeAt(0)); }
862
871
  async function enablePush() {
863
872
  try {
864
- if (isDemo()) throw new Error('Els avisos només funcionen amb un ordinador de veritat');
865
873
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error(isIOS ? 'Afegeix nio a la pantalla d’inici i obre’l des d’allà' : 'Aquest navegador no admet avisos');
866
874
  if ((await Notification.requestPermission()) !== 'granted') throw new Error('Cal que permetis les notificacions');
867
875
  const reg = await navigator.serviceWorker.ready;
@@ -871,11 +879,11 @@ async function enablePush() {
871
879
  } catch (e) { toast(e.message, 4200); }
872
880
  }
873
881
  async function syncServiceWorker() {
874
- const m = machine(); if (!m || m.url === DEMO_URL || !('serviceWorker' in navigator)) return;
882
+ const m = machine(); if (!m || !('serviceWorker' in navigator)) return;
875
883
  try { (await navigator.serviceWorker.ready).active?.postMessage({ type: 'nio-cfg', url: m.url, token: m.token }); } catch { /* sense service worker */ }
876
884
  }
877
885
 
878
- /* ---------------------------------------------------------------- model al mòbil (WebLLM) */
886
+ /* ---------------------------------------------------------------- xat local (WebLLM) */
879
887
  const LOCAL_MODELS = [
880
888
  { 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
889
  { id: 'Llama-3.2-1B-Instruct-q4f16_1-MLC', name: 'Llama 3.2 · 1B', size: '≈ 0,9 GB', note: 'Petit i equilibrat.' },
@@ -888,7 +896,7 @@ const stripThink = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trimSta
888
896
  function viewLocal() {
889
897
  const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
890
898
  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>
899
+ 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
900
  <div class="stack-lg" style="max-width:640px">
893
901
  ${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
902
  <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 +919,22 @@ function bindLocal() {
911
919
  const ta = $('#lmsg');
912
920
  ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; });
913
921
  ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
914
- $('#lsend').onclick = sendLocal;
922
+ $('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
923
+ setLocalBusy(local.busy);
924
+ }
925
+ function setLocalBusy(busy) {
926
+ const b = $('#lsend'); if (!b) return;
927
+ b.classList.toggle('stop', busy); b.innerHTML = svg(busy ? ICON.stop : ICON.send);
928
+ b.setAttribute('aria-label', busy ? 'Para' : 'Envia'); b.title = busy ? 'Para la resposta' : 'Envia';
915
929
  }
930
+ function stopLocal() { local.stopped = true; try { local.engine?.interruptGenerate(); } catch { /* res */ } }
931
+ const LOCAL_SYSTEM = [
932
+ '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.',
933
+ 'Respon directe, en una a quatre frases curtes. Una idea per frase.',
934
+ 'No repeteixis mai una frase ni una idea que ja hagis dit. Quan hagis contestat, para.',
935
+ 'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
936
+ 'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
937
+ ].join(' ');
916
938
  async function loadLocal(id) {
917
939
  local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
918
940
  try {
@@ -934,12 +956,21 @@ async function sendLocal() {
934
956
  local.messages.push({ role: 'user', content: text });
935
957
  feed.appendChild(el(`<div class="msg-user">${esc(text)}</div>`));
936
958
  const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); feed.scrollTop = feed.scrollHeight;
937
- local.busy = true; let acc = '';
959
+ local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
938
960
  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;
961
+ const chunks = await local.engine.chat.completions.create({
962
+ messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
963
+ temperature: 0.7, top_p: 0.8, frequency_penalty: 0.6, presence_penalty: 0.4, repetition_penalty: 1.15, max_tokens: 700,
964
+ extra_body: { enable_thinking: false }
965
+ });
966
+ for await (const c of chunks) {
967
+ acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
968
+ if (local.stopped) break;
969
+ }
970
+ } catch (e) { if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc)); }
971
+ out.classList.remove('cursor');
972
+ if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
973
+ local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
943
974
  }
944
975
 
945
976
  /* ---------------------------------------------------------------- accions */
@@ -948,7 +979,7 @@ document.addEventListener('click', async (e) => {
948
979
  if (!a) return;
949
980
  if (a.dataset.copy) { try { await navigator.clipboard.writeText(a.dataset.copy); toast('Copiat'); } catch { toast(a.dataset.copy); } return; }
950
981
  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(); }
982
+ 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
983
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
953
984
  switch (a.dataset.act) {
954
985
  case 'new': return newSessionSheet();
@@ -957,18 +988,21 @@ document.addEventListener('click', async (e) => {
957
988
  case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
958
989
  case 'ollama-del': return deleteModel(a.dataset.name);
959
990
  case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
960
- case 'demo': return tryDemo();
961
991
  case 'retry': return connect();
962
992
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
963
993
  case 'session-more': return sessionOptions(state.route.id);
964
994
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
965
995
  case 'push': return enablePush();
966
996
  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));
997
+ case 'add-machine': return addMachineSheet();
998
+ case 'machines': return machinesSheet();
999
+ case 'mobile-qr': return mobileQrSheet();
1000
+ case 'mobile-hint-off': LS.set('nio.mobHint.off', true); return render();
1001
+ case 'git': return gitSheet(state.route.id);
968
1002
  case 'forget': {
969
1003
  if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
970
1004
  const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
971
- streamCtrl?.abort(); unsubDemo?.(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
1005
+ streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
972
1006
  connect(); return render();
973
1007
  }
974
1008
  case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
@@ -978,6 +1012,7 @@ document.addEventListener('click', async (e) => {
978
1012
  case 'tour': return tourStart();
979
1013
  }
980
1014
  });
1015
+ 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
1016
  document.addEventListener('submit', (e) => {
982
1017
  if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
983
1018
  if (e.target.id !== 'pairForm') return;
@@ -993,7 +1028,6 @@ function commands() {
993
1028
  const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
994
1029
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
995
1030
  [...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}`) }));
996
- if (!isDemo()) c.push({ label: 'Prova l’ordinador simulat', hint: 'Prova', run: tryDemo });
997
1031
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
998
1032
  return c;
999
1033
  }
@@ -1069,8 +1103,7 @@ const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
1069
1103
  const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
1070
1104
  const TSTEPS = [
1071
1105
  { center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA del teu ordinador. En un minut t’ensenyo on és cada cosa.', next: 'Comencem' },
1072
- { route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix. Si encara no el tens a mà, pots provar-ho amb un ordinador simulat.') },
1073
- { route: 'sessions', target: () => (machine() ? null : '.try'), m: () => (machine() ? null : '.try [data-act=demo]'), skipIf: () => !!machine(), title: 'Prova-ho sense instal·lar res', text: 'L’ordinador simulat té un agent de demostració que llegeix, et demana permís i executa. Ideal per veure com funciona.' },
1106
+ { route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix.') },
1074
1107
  { route: 'approvals', target: '.side nav a[href="#/aprovacions"]', m: '#mRight .icon-btn', title: '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.' },
1075
1108
  { route: 'mobil', target: '.side nav a[href="#/mobil"]', 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.' },
1076
1109
  { route: 'models', target: '.side nav a[href="#/models"]', m: '.page .top h1, .connect-head h1', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models, veus quins tens i obres sessions amb el que vulguis. Res no surt de casa.' },
@@ -1172,7 +1205,7 @@ if ('serviceWorker' in navigator) {
1172
1205
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
1173
1206
  }
1174
1207
  addEventListener('hashchange', route);
1175
- document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected && !isDemo()) connect(); });
1208
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
1176
1209
  setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
1177
1210
  const qs = new URLSearchParams(location.search).get('s');
1178
1211
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);