nio-cat 0.1.0 → 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 +32 -1
- package/package.json +1 -1
- package/src/github.js +144 -0
- package/src/pair.js +5 -1
- package/src/server.js +26 -1
- package/web/app/app.js +1318 -0
- package/web/app/index.html +76 -0
- package/web/app/style.css +381 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/board.css +170 -0
- package/web/board.js +1180 -0
- package/web/bot.css +60 -0
- package/web/bot.js +48 -0
- package/web/favicon.svg +1 -0
- package/web/icon-192.png +0 -0
- package/web/icon-512.png +0 -0
- package/web/icon-maskable-512.png +0 -0
- package/web/index.html +140 -0
- package/web/manifest.webmanifest +17 -0
- package/web/og.png +0 -0
- package/web/sw.js +62 -0
package/web/app/app.js
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
// nio · app. Parla directament amb el programa nio del teu ordinador (fetch + flux d'esdeveniments).
|
|
2
|
+
// Sense frameworks ni compilació. També inclou un ordinador de prova que viu dins del navegador.
|
|
3
|
+
|
|
4
|
+
const $ = (s, e = document) => e.querySelector(s);
|
|
5
|
+
const $$ = (s, e = document) => [...e.querySelectorAll(s)];
|
|
6
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
7
|
+
const el = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
|
|
8
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
const rid = (n = 8) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(36).padStart(2, '0')).join('').slice(0, n + 3);
|
|
10
|
+
const isTouch = matchMedia('(pointer: coarse)').matches;
|
|
11
|
+
const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
12
|
+
const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
|
|
13
|
+
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
14
|
+
const bot = (mood = 'idle', cls = '') => (window.NioBot ? NioBot.html(mood, cls) : '');
|
|
15
|
+
const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
16
|
+
const ICON = {
|
|
17
|
+
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
18
|
+
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
19
|
+
phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
|
|
20
|
+
machine: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
|
|
21
|
+
board: '<path d="M4 20l1.2-4.4L16 4.8a2 2 0 0 1 2.9 0l.3.3a2 2 0 0 1 0 2.9L8.4 18.8z"/>',
|
|
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
|
+
trash: '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
|
|
24
|
+
download: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
|
|
25
|
+
chev: '<path d="m6 9 6 6 6-6"/>',
|
|
26
|
+
plus: '<path d="M12 5v14M5 12h14"/>', send: '<path d="M12 19V5M5 12l7-7 7 7"/>',
|
|
27
|
+
stop: '<rect x="7" y="7" width="10" height="10" rx="1.5" fill="currentColor" stroke="none"/>',
|
|
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
|
+
refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>', back: '<path d="M15 5l-7 7 7 7"/>',
|
|
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"/>',
|
|
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"/>'
|
|
32
|
+
};
|
|
33
|
+
const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
|
|
34
|
+
const MODES = {
|
|
35
|
+
llegir: { label: 'Només llegir', short: 'Llegir' },
|
|
36
|
+
preguntar: { label: 'Demana permís', short: 'Permís' },
|
|
37
|
+
tot: { label: 'Via lliure', short: 'Lliure' }
|
|
38
|
+
};
|
|
39
|
+
const DEMO_URL = 'demo:navegador';
|
|
40
|
+
|
|
41
|
+
/* ---------------------------------------------------------------- estat */
|
|
42
|
+
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() };
|
|
43
|
+
const LS = {
|
|
44
|
+
get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
|
45
|
+
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
|
|
46
|
+
};
|
|
47
|
+
const store = {
|
|
48
|
+
get machines() { return LS.get('nio.machines', []); },
|
|
49
|
+
set machines(v) { LS.set('nio.machines', v); },
|
|
50
|
+
get active() { return Number(LS.get('nio.active', 0)) || 0; },
|
|
51
|
+
set active(i) { LS.set('nio.active', i); }
|
|
52
|
+
};
|
|
53
|
+
const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
54
|
+
const isDemo = () => machine()?.url === DEMO_URL;
|
|
55
|
+
|
|
56
|
+
/* ---------------------------------------------------------------- utilitats */
|
|
57
|
+
function toast(msg, ms = 2800) {
|
|
58
|
+
const box = $('#toasts'), t = document.createElement('div');
|
|
59
|
+
t.className = 'toast'; t.textContent = msg;
|
|
60
|
+
while (box.children.length >= 2) box.firstElementChild.remove();
|
|
61
|
+
box.appendChild(t); setTimeout(() => t.remove(), ms);
|
|
62
|
+
}
|
|
63
|
+
function ago(ts) {
|
|
64
|
+
const s = Math.round((Date.now() - ts) / 1000);
|
|
65
|
+
if (s < 45) return 'ara mateix';
|
|
66
|
+
if (s < 3600) return `fa ${Math.round(s / 60)} min`;
|
|
67
|
+
if (s < 86400) return `fa ${Math.round(s / 3600)} h`;
|
|
68
|
+
return new Date(ts).toLocaleDateString('ca', { day: 'numeric', month: 'short' });
|
|
69
|
+
}
|
|
70
|
+
const bytes = (n) => { if (!n) return ''; const u = ['B', 'kB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1000 && i < u.length - 1) { n /= 1000; i++; } return `${n.toLocaleString('ca', { maximumFractionDigits: n < 10 ? 1 : 0 })} ${u[i]}`; };
|
|
71
|
+
const basename = (p) => String(p || '').split(/[\\/]/).filter(Boolean).pop() || p;
|
|
72
|
+
function md(src) {
|
|
73
|
+
// Markdown mínim i segur: codi, negreta, enllaços, títols i llistes.
|
|
74
|
+
const out = [];
|
|
75
|
+
String(src || '').split(/```/).forEach((part, i) => {
|
|
76
|
+
if (i % 2 === 1) { out.push(`<pre><code>${esc(part.replace(/^[\w+-]*\n/, '').replace(/\n$/, ''))}</code></pre>`); return; }
|
|
77
|
+
const inline = (t) => esc(t).replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
78
|
+
let list = null, para = [];
|
|
79
|
+
const fp = () => { if (para.length) out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; };
|
|
80
|
+
const fl = () => { if (list) out.push(`<${list.tag}>${list.items.map((x) => `<li>${inline(x)}</li>`).join('')}</${list.tag}>`); list = null; };
|
|
81
|
+
for (const line of part.split('\n')) {
|
|
82
|
+
const ul = line.match(/^\s*[-*•]\s+(.*)/), ol = line.match(/^\s*\d+[.)]\s+(.*)/), hd = line.match(/^#{1,4}\s+(.*)/);
|
|
83
|
+
if (ul || ol) { fp(); const tag = ul ? 'ul' : 'ol'; if (!list || list.tag !== tag) { fl(); list = { tag, items: [] }; } list.items.push((ul || ol)[1]); }
|
|
84
|
+
else if (hd) { fp(); fl(); out.push(`<h3>${inline(hd[1])}</h3>`); }
|
|
85
|
+
else if (!line.trim()) { fp(); fl(); }
|
|
86
|
+
else { fl(); para.push(line); }
|
|
87
|
+
}
|
|
88
|
+
fp(); fl();
|
|
89
|
+
});
|
|
90
|
+
return out.join('');
|
|
91
|
+
}
|
|
92
|
+
function toolArg(input) {
|
|
93
|
+
if (!input || typeof input !== 'object') return '';
|
|
94
|
+
const v = input.command || input.file_path || input.path || input.pattern || input.url || input.query || input.description || (input.fitxers && input.fitxers.join(', ')) || Object.values(input).find((x) => typeof x === 'string');
|
|
95
|
+
return String(v ?? '').split('\n')[0];
|
|
96
|
+
}
|
|
97
|
+
function prettyInput(input) {
|
|
98
|
+
if (!input) return '';
|
|
99
|
+
if (input.command) return input.command;
|
|
100
|
+
if (input.content && input.file_path) return `${input.file_path}\n\n${input.content}`;
|
|
101
|
+
if (input.new_string != null && input.file_path) return `${input.file_path}\n\n− ${String(input.old_string || '').split('\n').join('\n− ')}\n+ ${String(input.new_string).split('\n').join('\n+ ')}`;
|
|
102
|
+
return JSON.stringify(input, null, 2);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* ---------------------------------------------------------------- ordinador de prova (dins del navegador) */
|
|
106
|
+
const demo = (() => {
|
|
107
|
+
const listeners = new Set();
|
|
108
|
+
const sessions = new Map(), approvals = new Map(), notices = [];
|
|
109
|
+
const runs = new Map();
|
|
110
|
+
const omodels = [{ name: 'qwen3:4b', size: 2.5e9, params: '4.0B', quant: 'Q4_K_M' }], opulls = new Map();
|
|
111
|
+
const emit = (msg) => listeners.forEach((f) => f(JSON.parse(JSON.stringify(msg))));
|
|
112
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
113
|
+
const summary = (s) => {
|
|
114
|
+
const last = [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user');
|
|
115
|
+
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 };
|
|
116
|
+
};
|
|
117
|
+
const touch = (s) => { s.updated = Date.now(); emit({ type: 'session', session: summary(s) }); };
|
|
118
|
+
const push = (s, kind, data = {}) => {
|
|
119
|
+
const lastEv = s.events[s.events.length - 1];
|
|
120
|
+
if (lastEv && lastEv.open) delete lastEv.open;
|
|
121
|
+
const ev = { seq: ++s.seq, t: Date.now(), kind, ...data }; s.events.push(ev);
|
|
122
|
+
emit({ type: 'event', session: s.id, event: ev }); touch(s); return ev;
|
|
123
|
+
};
|
|
124
|
+
const chunk = (s, text) => {
|
|
125
|
+
const last = s.events[s.events.length - 1];
|
|
126
|
+
if (last && last.kind === 'text' && last.open) { last.text += text; emit({ type: 'event', session: s.id, event: last }); return; }
|
|
127
|
+
const ev = push(s, 'text', { text }); ev.open = true;
|
|
128
|
+
};
|
|
129
|
+
const pub = (a) => { const { resolve, ...r } = a; return r; };
|
|
130
|
+
function ask(s, tool, input) {
|
|
131
|
+
const a = { id: rid(), session: s.id, agent: s.agent, title: s.title, tool, input, state: 'pending', created: Date.now() };
|
|
132
|
+
const p = new Promise((res) => (a.resolve = res));
|
|
133
|
+
approvals.set(a.id, a); s.state = 'waiting';
|
|
134
|
+
push(s, 'approval', { approval: a.id, tool, input, state: 'pending' });
|
|
135
|
+
emit({ type: 'approval', approval: pub(a) });
|
|
136
|
+
notices.unshift({ id: rid(4), t: Date.now(), title: 'Demo et demana permís', body: `${tool}: ${input.command || ''}`, session: s.id });
|
|
137
|
+
return p;
|
|
138
|
+
}
|
|
139
|
+
function decide(id, decision) {
|
|
140
|
+
const a = approvals.get(id); if (!a || a.state !== 'pending') return null;
|
|
141
|
+
a.state = decision === 'allow' ? 'allowed' : 'denied';
|
|
142
|
+
const s = sessions.get(a.session);
|
|
143
|
+
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); }
|
|
144
|
+
emit({ type: 'approval', approval: pub(a) }); a.resolve(a.state === 'allowed' ? 'allow' : 'deny');
|
|
145
|
+
return pub(a);
|
|
146
|
+
}
|
|
147
|
+
async function run(s, prompt) {
|
|
148
|
+
let stopped = false; runs.set(s.id, { stop: () => (stopped = true) });
|
|
149
|
+
if (s.agent === 'ollama') {
|
|
150
|
+
try {
|
|
151
|
+
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); }
|
|
152
|
+
push(s, 'result', { ms: 1800 });
|
|
153
|
+
} catch { push(s, 'info', { text: 'Has aturat l’agent' }); }
|
|
154
|
+
runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 }); return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
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); }
|
|
158
|
+
push(s, 'tool', { id: 't1', name: 'Read', input: { file_path: 'README.md' } }); await sleep(450);
|
|
159
|
+
push(s, 'tool_result', { id: 't1', name: 'Read', output: '# Projecte de prova\nAixò és un README d’exemple.' });
|
|
160
|
+
if (stopped) throw 0;
|
|
161
|
+
const input = { command: 'npm test' };
|
|
162
|
+
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`.' });
|
|
163
|
+
else {
|
|
164
|
+
const ok = s.mode === 'tot' ? true : (await ask(s, 'Bash', input)) === 'allow';
|
|
165
|
+
if (stopped) throw 0;
|
|
166
|
+
push(s, 'tool', { id: 't2', name: 'Bash', input }); await sleep(380);
|
|
167
|
+
if (ok) { push(s, 'tool_result', { id: 't2', name: 'Bash', output: '✓ 12 tests passen' }); push(s, 'text', { text: 'Fet. Tots els tests passen.' }); }
|
|
168
|
+
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?' }); }
|
|
169
|
+
}
|
|
170
|
+
push(s, 'result', { cost: 0, ms: 2400 });
|
|
171
|
+
runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 });
|
|
172
|
+
} catch {
|
|
173
|
+
runs.delete(s.id); s.state = 'idle'; push(s, 'info', { text: 'Has aturat l’agent' }); push(s, 'status', { state: 'idle', code: 130 });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const info = () => ({ name: 'nio', version: 'prova', host: 'Ordinador de prova', platform: 'navegador', user: 'tu', publicUrl: DEMO_URL, roots: ['~/projectes'], vapidKey: null,
|
|
177
|
+
agents: [
|
|
178
|
+
{ id: 'demo', name: 'Demo', available: true, version: 'simulat', approvals: true, login: 'No cal instal·lar res' },
|
|
179
|
+
{ id: 'claude', name: 'Claude Code', available: false, approvals: true, login: 'Instal·la nio al teu ordinador' },
|
|
180
|
+
{ id: 'codex', name: 'Codex', available: false, login: 'Instal·la nio al teu ordinador' },
|
|
181
|
+
{ id: 'grok', name: 'Grok', available: false, beta: true, login: 'Instal·la nio al teu ordinador' },
|
|
182
|
+
{ id: 'antigravity', name: 'Antigravity', available: false, beta: true, login: 'Instal·la nio al teu ordinador' }
|
|
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/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: [] };
|
|
201
|
+
if (p === '/api/ollama/pull' && method === 'POST') {
|
|
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');
|
|
203
|
+
if (opulls.has(name) || omodels.some((m) => m.name === name)) return { ok: true, already: true };
|
|
204
|
+
const total = 1.2e9 + Math.random() * 3e9, job = { name, status: 'pulling manifest', completed: 0, total };
|
|
205
|
+
job.t = setInterval(() => {
|
|
206
|
+
job.completed = Math.min(total, job.completed + total / 18); job.status = job.completed < total ? 'pulling' : 'success';
|
|
207
|
+
emit({ type: 'ollama', name, status: job.status, completed: job.completed, total });
|
|
208
|
+
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 }); }
|
|
209
|
+
}, 260);
|
|
210
|
+
opulls.set(name, job); return { ok: true };
|
|
211
|
+
}
|
|
212
|
+
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 }; }
|
|
213
|
+
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 }; }
|
|
214
|
+
if (p === '/api/sessions' && method === 'GET') return [...sessions.values()].sort((a, b) => b.updated - a.updated).map(summary);
|
|
215
|
+
if (p === '/api/sessions' && method === 'POST') {
|
|
216
|
+
if (body.agent !== 'demo' && body.agent !== 'ollama') throw err(400, 'A l’ordinador de prova només hi ha els agents Demo i Ollama');
|
|
217
|
+
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: [] };
|
|
218
|
+
sessions.set(s.id, s); touch(s);
|
|
219
|
+
if (body.text) send(s.id, body.text);
|
|
220
|
+
return summary(s);
|
|
221
|
+
}
|
|
222
|
+
if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
|
|
223
|
+
const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
|
|
224
|
+
if (method === 'GET') return { session: summary(s), events: s.events.filter((e) => e.seq > Number(url.searchParams.get('since') || 0)) };
|
|
225
|
+
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); }
|
|
226
|
+
if (method === 'DELETE') { runs.get(s.id)?.stop(); sessions.delete(s.id); emit({ type: 'removed', session: s.id }); return { ok: true }; }
|
|
227
|
+
}
|
|
228
|
+
if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/message$/))) return send(m[1], body.text);
|
|
229
|
+
if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/stop$/))) { const r = runs.get(m[1]); r?.stop(); return { stopped: !!r }; }
|
|
230
|
+
if (p === '/api/approvals') return [...approvals.values()].filter((a) => a.state === 'pending').map(pub);
|
|
231
|
+
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; }
|
|
232
|
+
if (p === '/api/notices') return notices.slice(0, 5);
|
|
233
|
+
if (p.startsWith('/api/push')) throw err(400, 'Els avisos només funcionen amb un ordinador de veritat');
|
|
234
|
+
throw err(404, 'Aquesta ruta no existeix');
|
|
235
|
+
}
|
|
236
|
+
function send(sid, text) {
|
|
237
|
+
const s = sessions.get(sid); if (!s) throw err(404, 'Aquesta sessió no existeix');
|
|
238
|
+
if (runs.has(sid)) throw err(409, 'L’agent encara està treballant. Espera que acabi o atura’l.');
|
|
239
|
+
text = String(text || '').trim(); if (!text) throw err(400, 'El missatge és buit');
|
|
240
|
+
if (!s.title) s.title = text.slice(0, 60);
|
|
241
|
+
push(s, 'user', { text }); s.state = 'running'; push(s, 'status', { state: 'running' });
|
|
242
|
+
run(s, text); return summary(s);
|
|
243
|
+
}
|
|
244
|
+
function subscribe(fn) {
|
|
245
|
+
listeners.add(fn);
|
|
246
|
+
fn({ type: 'hello', sessions: [...sessions.values()].map(summary), approvals: [...approvals.values()].filter((a) => a.state === 'pending').map(pub) });
|
|
247
|
+
return () => listeners.delete(fn);
|
|
248
|
+
}
|
|
249
|
+
return { request, subscribe };
|
|
250
|
+
})();
|
|
251
|
+
|
|
252
|
+
/* ---------------------------------------------------------------- API */
|
|
253
|
+
async function api(path, opts = {}) {
|
|
254
|
+
const m = machine();
|
|
255
|
+
if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
256
|
+
const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
|
|
257
|
+
if (m.url === DEMO_URL) return demo.request(opts.method || 'GET', path, body || {});
|
|
258
|
+
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 });
|
|
259
|
+
const data = await res.json().catch(() => ({}));
|
|
260
|
+
if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
|
|
261
|
+
return data;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let streamCtrl = null, unsubDemo = null, retry = 0;
|
|
265
|
+
async function connect() {
|
|
266
|
+
streamCtrl?.abort(); unsubDemo?.(); unsubDemo = null;
|
|
267
|
+
const m = machine();
|
|
268
|
+
if (!m) { state.connected = false; return render(); }
|
|
269
|
+
if (m.url === DEMO_URL) {
|
|
270
|
+
state.info = await api('/api/info'); state.connected = true; state.lastError = null;
|
|
271
|
+
unsubDemo = demo.subscribe(handle); render(); brainsForBoard(); return;
|
|
272
|
+
}
|
|
273
|
+
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
274
|
+
try {
|
|
275
|
+
state.info = await api('/api/info');
|
|
276
|
+
const res = await fetch(`${m.url}/api/stream`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
|
|
277
|
+
if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
|
|
278
|
+
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
279
|
+
state.connected = true; state.lastError = null; retry = 0;
|
|
280
|
+
renameMachine(state.info.host); syncServiceWorker(); render(); brainsForBoard();
|
|
281
|
+
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
282
|
+
for (;;) {
|
|
283
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
284
|
+
buf += dec.decode(value, { stream: true }); let i;
|
|
285
|
+
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
286
|
+
const block = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
287
|
+
const data = block.split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.slice(6)).join('\n');
|
|
288
|
+
if (data) handle(JSON.parse(data));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
throw new Error('S’ha tallat la connexió');
|
|
292
|
+
} catch (e) {
|
|
293
|
+
if (ctrl.signal.aborted) return;
|
|
294
|
+
state.connected = false;
|
|
295
|
+
state.lastError = e.message === 'Failed to fetch' || e.name === 'TypeError' ? 'No arribo a l’ordinador' : e.message;
|
|
296
|
+
render();
|
|
297
|
+
if (e.fatal) return;
|
|
298
|
+
setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function renameMachine(host) { const ms = store.machines, m = ms[store.active]; if (m && host && m.name !== host) { m.name = host; store.machines = ms; } }
|
|
302
|
+
|
|
303
|
+
function handle(msg) {
|
|
304
|
+
const r = state.route;
|
|
305
|
+
switch (msg.type) {
|
|
306
|
+
case 'hello':
|
|
307
|
+
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
308
|
+
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
309
|
+
state.loaded.clear(); state.events.clear();
|
|
310
|
+
if (r.name === 'session') openSession(r.id); else render();
|
|
311
|
+
break;
|
|
312
|
+
case 'session':
|
|
313
|
+
state.sessions.set(msg.session.id, msg.session);
|
|
314
|
+
if (r.name === 'session' && r.id === msg.session.id) { renderSessionHead(); renderComposer(); renderTyping(); }
|
|
315
|
+
else if (r.name === 'sessions') renderSoon();
|
|
316
|
+
renderChrome();
|
|
317
|
+
break;
|
|
318
|
+
case 'removed':
|
|
319
|
+
state.sessions.delete(msg.session);
|
|
320
|
+
if (r.name === 'session' && r.id === msg.session) go('#/sessions'); else renderSoon();
|
|
321
|
+
break;
|
|
322
|
+
case 'event': {
|
|
323
|
+
const evs = state.events.get(msg.session);
|
|
324
|
+
if (evs) { evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) putEvent(msg.event); }
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case 'ollama': onOllama(msg); break;
|
|
328
|
+
case 'approval':
|
|
329
|
+
if (msg.approval.state === 'pending') { state.approvals.set(msg.approval.id, msg.approval); if (!document.hidden && navigator.vibrate) navigator.vibrate(30); }
|
|
330
|
+
else if (state.approvals.has(msg.approval.id)) { markDecided(msg.approval.id, msg.approval.state); break; }
|
|
331
|
+
renderChrome();
|
|
332
|
+
if (r.name === 'approvals') render();
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
let soonT = 0;
|
|
337
|
+
const renderSoon = () => { clearTimeout(soonT); soonT = setTimeout(() => state.route.name === 'sessions' && render(), 120); };
|
|
338
|
+
|
|
339
|
+
/* ---------------------------------------------------------------- connexió amb un ordinador */
|
|
340
|
+
function parsePair(text) {
|
|
341
|
+
const s = String(text || '').trim(); let hash = '';
|
|
342
|
+
try { hash = new URL(s).hash; } catch { hash = s.startsWith('#') ? s : s.includes('u=') ? `#${s.split('#').pop()}` : ''; }
|
|
343
|
+
const p = new URLSearchParams(hash.replace(/^#/, '')), url = p.get('u'), token = p.get('t');
|
|
344
|
+
if (!url || !token) return null;
|
|
345
|
+
return { url: url.replace(/\/$/, ''), token };
|
|
346
|
+
}
|
|
347
|
+
function addMachine({ url, token, name }) {
|
|
348
|
+
const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
|
|
349
|
+
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; }
|
|
350
|
+
store.machines = ms; store.active = i;
|
|
351
|
+
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
352
|
+
connect();
|
|
353
|
+
}
|
|
354
|
+
function tryDemo() { addMachine({ url: DEMO_URL, token: 'demo', name: 'Ordinador de prova' }); toast('Connectat a l’ordinador de prova'); go('#/sessions'); }
|
|
355
|
+
function checkPairHash() {
|
|
356
|
+
const pair = parsePair(location.hash); if (!pair) return false;
|
|
357
|
+
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
358
|
+
state.route = { name: 'sessions' };
|
|
359
|
+
addMachine(pair); render(); toast('Ordinador connectat'); return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* ---------------------------------------------------------------- navegació */
|
|
363
|
+
const VIEWS = {
|
|
364
|
+
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
365
|
+
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
366
|
+
models: { label: 'Models', icon: ICON.models, group: 'Ordinador' },
|
|
367
|
+
maquina: { label: 'Ordinador', icon: ICON.machine, group: 'Ordinador' },
|
|
368
|
+
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
369
|
+
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
|
|
370
|
+
};
|
|
371
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
|
|
372
|
+
function parseRoute() {
|
|
373
|
+
const h = location.hash.replace(/^#\/?/, '');
|
|
374
|
+
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
375
|
+
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
376
|
+
return { name: k || 'sessions' };
|
|
377
|
+
}
|
|
378
|
+
function go(hash) { if (location.hash === hash) route(); else location.hash = hash; }
|
|
379
|
+
let lastName = null;
|
|
380
|
+
function route() {
|
|
381
|
+
if (checkPairHash()) return;
|
|
382
|
+
state.route = parseRoute();
|
|
383
|
+
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ---------------------------------------------------------------- pintat general */
|
|
387
|
+
function renderChrome() {
|
|
388
|
+
const r = state.route, n = state.approvals.size, active = r.name === 'session' ? 'sessions' : r.name;
|
|
389
|
+
let prev = '';
|
|
390
|
+
$('#nav').innerHTML = Object.entries(VIEWS).map(([k, v], i) => {
|
|
391
|
+
const head = v.group !== prev ? `<span class="grp" style="--i:${i}">${v.group}</span>` : '';
|
|
392
|
+
prev = v.group;
|
|
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>`;
|
|
394
|
+
}).join('');
|
|
395
|
+
const m = machine();
|
|
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'));
|
|
399
|
+
const s = r.name === 'session' ? state.sessions.get(r.id) : null;
|
|
400
|
+
$('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
|
|
401
|
+
$('#mRight').innerHTML = r.name === 'session'
|
|
402
|
+
? `<button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button>`
|
|
403
|
+
: `<a class="icon-btn" href="#/aprovacions" aria-label="${n ? `Aprovacions, ${n} pendents` : 'Aprovacions'}">${svg(ICON.bell)}${n ? `<span class="badge">${n}</span>` : ''}</a>`;
|
|
404
|
+
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
405
|
+
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
406
|
+
}
|
|
407
|
+
function render() {
|
|
408
|
+
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
409
|
+
renderChrome();
|
|
410
|
+
document.body.classList.toggle('board-mode', board);
|
|
411
|
+
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
|
|
412
|
+
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
413
|
+
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
414
|
+
const changed = lastName !== r.name || r.name === 'session';
|
|
415
|
+
lastName = r.name;
|
|
416
|
+
if (r.name === 'session') renderSession();
|
|
417
|
+
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
418
|
+
else {
|
|
419
|
+
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
420
|
+
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>`;
|
|
421
|
+
if (r.name === 'mobil') bindLocal();
|
|
422
|
+
if (r.name === 'models' && machine()) loadOllama();
|
|
423
|
+
}
|
|
424
|
+
if (changed && !board) { view.classList.remove('view-enter'); void view.offsetWidth; view.classList.add('view-enter'); }
|
|
425
|
+
window.NioBot?.refresh();
|
|
426
|
+
tourUpdate();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ---------- Connecta */
|
|
430
|
+
function viewConnect(compact = false) {
|
|
431
|
+
const CMD = 'npx nio-cat';
|
|
432
|
+
const main = `<div class="panel conn-main">
|
|
433
|
+
<ol class="stepper">
|
|
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>
|
|
435
|
+
<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>
|
|
436
|
+
<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>
|
|
437
|
+
</ol>
|
|
438
|
+
<div class="cmd big"><code>${CMD}</code><button class="btn sm" data-copy="${CMD}" type="button">${svg(ICON.copy)}Copia</button></div>
|
|
439
|
+
<div class="reqs"><span class="chip">Node.js 18 o més nou</span><span class="chip ghost">cloudflared · opcional</span></div>
|
|
440
|
+
<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>
|
|
441
|
+
<details class="paste" ${compact ? 'open' : ''}><summary>Ja tens l’enllaç del terminal?</summary>
|
|
442
|
+
<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>
|
|
443
|
+
<p class="muted small">El trobaràs sota el codi QR. Va bé si fas servir nio des de la pantalla d’inici.</p>
|
|
444
|
+
</details>
|
|
445
|
+
</div>`;
|
|
446
|
+
if (compact) return main;
|
|
447
|
+
return `<section class="connect">
|
|
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>
|
|
449
|
+
<div class="connect-grid">
|
|
450
|
+
${main}
|
|
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>
|
|
452
|
+
</div>
|
|
453
|
+
</section>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/* ---------- Sessions */
|
|
457
|
+
function stateChip(s) {
|
|
458
|
+
if (s.pending || s.state === 'waiting') return '<span class="chip waiting"><i class="pulse"></i>Espera el teu permís</span>';
|
|
459
|
+
if (s.state === 'running') return '<span class="chip running"><i class="pulse"></i>Treballant</span>';
|
|
460
|
+
if (s.state === 'error') return '<span class="chip error">Error</span>';
|
|
461
|
+
return '';
|
|
462
|
+
}
|
|
463
|
+
function viewSessions() {
|
|
464
|
+
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
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}`;
|
|
469
|
+
if (!state.connected && !list.length) {
|
|
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>`;
|
|
471
|
+
}
|
|
472
|
+
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
473
|
+
<a class="card session-card" href="#/s/${esc(s.id)}">
|
|
474
|
+
<div class="row between"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
|
|
475
|
+
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
476
|
+
${s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
|
|
477
|
+
<div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
|
|
478
|
+
</a>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* ---------- Aprovacions */
|
|
482
|
+
function approvalCard(a, showSession = true) {
|
|
483
|
+
const decided = a.state && a.state !== 'pending';
|
|
484
|
+
if (decided && !showSession) {
|
|
485
|
+
// Ja resolta: una sola línia, perquè la conversa es llegeixi bé.
|
|
486
|
+
const ok = a.state === 'allowed';
|
|
487
|
+
return `<div class="approval done ${esc(a.state)}" data-approval="${esc(a.approval || a.id)}"><span class="verdict ${esc(a.state)}">${ok ? '✓ Aprovat' : '✕ Rebutjat'}</span><span class="what"><b>${esc(a.tool)}</b> <span class="mono">${esc(toolArg(a.input))}</span></span></div>`;
|
|
488
|
+
}
|
|
489
|
+
return `<div class="approval ${decided ? 'done' : ''}" data-approval="${esc(a.approval || a.id)}">
|
|
490
|
+
${showSession ? `<div class="row between"><span class="agent-tag" data-agent="${esc(a.agent)}">${esc(AGENT_NAMES[a.agent] || a.agent)}</span><a class="btn sm" href="#/s/${esc(a.session)}">Obre la sessió</a></div>${a.title ? `<div class="muted small">${esc(a.title)}</div>` : ''}` : ''}
|
|
491
|
+
<div class="q">Vol fer servir <span class="mono">${esc(a.tool)}</span></div>
|
|
492
|
+
<pre>${esc(prettyInput(a.input))}</pre>
|
|
493
|
+
${decided ? `<div class="verdict ${esc(a.state)}">${a.state === 'allowed' ? '✓ Ho has aprovat' : '✕ Ho has rebutjat'}</div>` : '<div class="actions"><button class="btn danger" data-decide="deny" type="button">Rebutja</button><button class="btn ok" data-decide="allow" type="button">Aprova</button></div>'}
|
|
494
|
+
</div>`;
|
|
495
|
+
}
|
|
496
|
+
function viewApprovals() {
|
|
497
|
+
const list = [...state.approvals.values()].sort((a, b) => a.created - b.created);
|
|
498
|
+
return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>
|
|
499
|
+
${list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
|
|
500
|
+
}
|
|
501
|
+
// Marca una aprovació com a resolta a tot arreu: a la llista, a la conversa i a l'esdeveniment desat.
|
|
502
|
+
function markDecided(id, st) {
|
|
503
|
+
state.approvals.delete(id);
|
|
504
|
+
for (const evs of state.events.values()) for (const ev of evs.values()) if (ev.kind === 'approval' && ev.approval === id) ev.state = st;
|
|
505
|
+
$$(`#feed [data-approval="${CSS.escape(id)}"]`).forEach((card) => {
|
|
506
|
+
const ev = [...(state.events.get(state.route.id)?.values() || [])].find((x) => x.kind === 'approval' && x.approval === id);
|
|
507
|
+
if (ev) card.replaceWith(el(approvalCard({ approval: id, tool: ev.tool, input: ev.input, state: st }, false)));
|
|
508
|
+
});
|
|
509
|
+
$$(`.page [data-approval="${CSS.escape(id)}"]`).forEach((card) => {
|
|
510
|
+
card.classList.add('done', 'leaving');
|
|
511
|
+
const acts = $('.actions', card); if (acts) acts.outerHTML = `<div class="verdict ${st}">${st === 'allowed' ? '✓ Ho has aprovat' : '✕ Ho has rebutjat'}</div>`;
|
|
512
|
+
setTimeout(() => { if (state.route.name === 'approvals') render(); }, 1100);
|
|
513
|
+
});
|
|
514
|
+
renderChrome();
|
|
515
|
+
}
|
|
516
|
+
async function decide(card, decision) {
|
|
517
|
+
const id = card.dataset.approval;
|
|
518
|
+
$$('button', card).forEach((b) => (b.disabled = true));
|
|
519
|
+
try { await api(`/api/approvals/${id}`, { method: 'POST', body: { decision } }); markDecided(id, decision === 'allow' ? 'allowed' : 'denied'); if (navigator.vibrate) navigator.vibrate(15); }
|
|
520
|
+
catch (e) { toast(e.message); $$('button', card).forEach((b) => (b.disabled = false)); }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* ---------- Sessió */
|
|
524
|
+
async function openSession(id) {
|
|
525
|
+
state.route = { name: 'session', id };
|
|
526
|
+
render();
|
|
527
|
+
if (!state.loaded.has(id) && machine()) {
|
|
528
|
+
try {
|
|
529
|
+
const data = await api(`/api/sessions/${id}`);
|
|
530
|
+
state.sessions.set(id, data.session);
|
|
531
|
+
state.events.set(id, new Map(data.events.map((e) => [e.seq, e])));
|
|
532
|
+
state.loaded.add(id);
|
|
533
|
+
if (state.route.name === 'session' && state.route.id === id) render();
|
|
534
|
+
} catch (e) { toast(e.message); }
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
let feedEls = new Map(), toolEls = new Map();
|
|
538
|
+
function renderSession() {
|
|
539
|
+
const id = state.route.id, view = $('#view');
|
|
540
|
+
view.innerHTML = `<section class="chat">
|
|
541
|
+
<div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
|
|
542
|
+
<div class="mode-bar" id="modeBar"></div>
|
|
543
|
+
<div class="feed" id="feed" aria-live="polite"></div>
|
|
544
|
+
<div class="composer" id="composer"></div>
|
|
545
|
+
</section>`;
|
|
546
|
+
feedEls = new Map(); toolEls = new Map();
|
|
547
|
+
renderSessionHead();
|
|
548
|
+
const feed = $('#feed'), evs = state.events.get(id);
|
|
549
|
+
if (!evs) feed.innerHTML = `<div class="ev-info">${machine() ? 'Carregant la conversa…' : 'Connecta un ordinador per veure aquesta sessió.'}</div>`;
|
|
550
|
+
else for (const e of [...evs.values()].sort((a, b) => a.seq - b.seq)) putEvent(e, false);
|
|
551
|
+
renderTyping(); renderComposer();
|
|
552
|
+
requestAnimationFrame(() => (feed.scrollTop = feed.scrollHeight));
|
|
553
|
+
}
|
|
554
|
+
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
555
|
+
function renderSessionHead() {
|
|
556
|
+
renderChrome();
|
|
557
|
+
const s = state.sessions.get(state.route.id), bar = $('#modeBar');
|
|
558
|
+
if (!bar) return;
|
|
559
|
+
$('#sessTitle').textContent = s?.title || 'Sessió nova';
|
|
560
|
+
$('#sessSub').textContent = s ? `${AGENT_NAMES[s.agent] || s.agent} · ${basename(s.cwd)}` : '';
|
|
561
|
+
const hb = $('#headBot'); if (hb) { hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
|
|
562
|
+
if (!s) { bar.innerHTML = ''; return; }
|
|
563
|
+
const ag = state.info?.agents?.find((a) => a.id === s.agent);
|
|
564
|
+
bar.innerHTML = `<span class="chip"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
|
|
565
|
+
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
|
|
566
|
+
<span class="chip mono">${esc(basename(s.cwd))}</span>
|
|
567
|
+
${s.model ? `<span class="chip mono">${esc(s.model)}</span>` : ''}
|
|
568
|
+
${s.cost ? `<span class="chip mono">${s.cost.toFixed(2)} $</span>` : ''}
|
|
569
|
+
${stateChip(s)}`;
|
|
570
|
+
}
|
|
571
|
+
const nearBottom = (f) => f.scrollHeight - f.scrollTop - f.clientHeight < 140;
|
|
572
|
+
function putEvent(e, scroll = true) {
|
|
573
|
+
const feed = $('#feed'); if (!feed) return;
|
|
574
|
+
const stick = nearBottom(feed);
|
|
575
|
+
if (e.kind === 'tool_result') {
|
|
576
|
+
const t = toolEls.get(e.id);
|
|
577
|
+
if (t) {
|
|
578
|
+
const st = $('.st', t); st.className = `st ${e.error ? 'err' : 'ok'}`; st.textContent = e.error ? '✕' : '✓';
|
|
579
|
+
let pre = $('pre.out', t); if (!pre && e.output) { pre = el('<pre class="out"></pre>'); t.appendChild(pre); }
|
|
580
|
+
if (pre) pre.textContent = e.output;
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (e.kind === 'status') { renderTyping(); return; }
|
|
585
|
+
if (e.kind === 'log') {
|
|
586
|
+
const last = $('.typing', feed) ? $('.typing', feed).previousElementSibling : feed.lastElementChild;
|
|
587
|
+
if (last && last.classList.contains('ev-logs')) { const pre = $('pre', last); pre.textContent += `\n${e.text}`; $('summary', last).textContent = `Registre del procés (${pre.textContent.split('\n').length} línies)`; return; }
|
|
588
|
+
}
|
|
589
|
+
const node = eventNode(e); if (!node) return;
|
|
590
|
+
const old = feedEls.get(e.seq);
|
|
591
|
+
if (old) old.replaceWith(node); else { const typing = $('.typing', feed); typing ? feed.insertBefore(node, typing) : feed.appendChild(node); }
|
|
592
|
+
feedEls.set(e.seq, node);
|
|
593
|
+
if (e.kind === 'tool') toolEls.set(e.id, node);
|
|
594
|
+
renderTyping();
|
|
595
|
+
if (scroll && stick) feed.scrollTop = feed.scrollHeight;
|
|
596
|
+
}
|
|
597
|
+
function eventNode(e) {
|
|
598
|
+
const s = state.sessions.get(state.route.id);
|
|
599
|
+
switch (e.kind) {
|
|
600
|
+
case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
|
|
601
|
+
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}">${md(e.text)}</div>`);
|
|
602
|
+
case 'tool': return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
|
|
603
|
+
case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
|
|
604
|
+
case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
|
|
605
|
+
case 'error': return el(`<div class="ev-error">${esc(e.text)}</div>`);
|
|
606
|
+
case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
|
|
607
|
+
case 'result': {
|
|
608
|
+
const bits = [];
|
|
609
|
+
if (typeof e.cost === 'number' && e.cost > 0) bits.push(`${e.cost.toFixed(3)} $`);
|
|
610
|
+
if (e.ms) bits.push(`${(e.ms / 1000).toFixed(1).replace('.', ',')} s`);
|
|
611
|
+
if (e.tokens?.output_tokens || e.tokens?.output) bits.push(`${e.tokens.output_tokens || e.tokens.output} tokens`);
|
|
612
|
+
if (e.error) bits.push(String(e.error));
|
|
613
|
+
return bits.length ? el(`<div class="ev-result">${bits.map((b) => `<span>${esc(b)}</span>`).join('<span>·</span>')}</div>`) : null;
|
|
614
|
+
}
|
|
615
|
+
case 'log': return el(`<details class="ev-logs"><summary>Registre del procés (1 línia)</summary><pre>${esc(e.text)}</pre></details>`);
|
|
616
|
+
default: return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function renderTyping() {
|
|
620
|
+
const feed = $('#feed'); if (!feed) return;
|
|
621
|
+
const s = state.sessions.get(state.route.id), busy = s && s.state === 'running', evs = state.events.get(state.route.id);
|
|
622
|
+
const last = evs ? [...evs.values()].sort((a, b) => b.seq - a.seq).find((x) => x.kind !== 'status' && x.kind !== 'log') : null;
|
|
623
|
+
const streaming = last && last.kind === 'text' && last.open;
|
|
624
|
+
let t = $('.typing', feed);
|
|
625
|
+
if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${bot('work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
|
|
626
|
+
else if (t) t.remove();
|
|
627
|
+
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
628
|
+
const hb = $('#headBot .bot'); if (hb) hb.dataset.mood = sessionMood(s);
|
|
629
|
+
}
|
|
630
|
+
function renderComposer() {
|
|
631
|
+
const box = $('#composer'); if (!box) return;
|
|
632
|
+
const s = state.sessions.get(state.route.id), busy = !!s && (s.state === 'running' || s.state === 'waiting');
|
|
633
|
+
const prev = $('textarea', box)?.value || '';
|
|
634
|
+
if (box.dataset.busy === String(busy) && $('textarea', box)) return;
|
|
635
|
+
box.dataset.busy = String(busy);
|
|
636
|
+
box.innerHTML = `<div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
637
|
+
const ta = $('#msg'); ta.value = prev;
|
|
638
|
+
const grow = () => { ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; };
|
|
639
|
+
grow(); ta.addEventListener('input', grow);
|
|
640
|
+
const send = async () => {
|
|
641
|
+
const text = ta.value.trim(); if (!text || !s) return;
|
|
642
|
+
ta.value = ''; grow();
|
|
643
|
+
try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
|
|
644
|
+
catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
|
|
645
|
+
};
|
|
646
|
+
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); } });
|
|
647
|
+
$('#sendBtn').onclick = async () => { if (busy) { try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); } catch (e) { toast(e.message); } } else send(); };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/* ---------- Fulls */
|
|
651
|
+
function openSheet(html, onMount) {
|
|
652
|
+
const d = $('#sheet'); $('#sheetIn').innerHTML = `<div class="grab"></div>${html}`;
|
|
653
|
+
if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
|
|
654
|
+
onMount?.($('#sheetIn'));
|
|
655
|
+
}
|
|
656
|
+
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
657
|
+
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
658
|
+
|
|
659
|
+
function modeHelp(agentId, mode) {
|
|
660
|
+
const a = state.info?.agents?.find((x) => x.id === agentId);
|
|
661
|
+
if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
|
|
662
|
+
if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
|
|
663
|
+
if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
|
|
664
|
+
if (agentId === 'codex') return 'Pot editar dins la carpeta, sense accés a internet. Codex no pot fer preguntes en aquest mode.';
|
|
665
|
+
return 'Aquest agent encara no pot demanar permís: les accions que el necessitarien es rebutgen.';
|
|
666
|
+
}
|
|
667
|
+
async function newSessionSheet(preset = {}) {
|
|
668
|
+
if (!machine()) return go('#/sessions');
|
|
669
|
+
if (!state.connected) return toast('Encara no hi ha connexió amb l’ordinador');
|
|
670
|
+
const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
|
|
671
|
+
let projects = [];
|
|
672
|
+
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
673
|
+
const first = (isDemo() && agents.find((a) => a.id === 'demo')) || agents.find((a) => a.available && a.id !== 'demo') || agents.find((a) => a.available);
|
|
674
|
+
const sel = { agent: first?.id || 'demo', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path, mode: 'preguntar', model: '', ...preset };
|
|
675
|
+
openSheet(`<h2>Nova sessió</h2>
|
|
676
|
+
<div class="stack"><div class="section-title" style="margin:0">Agent</div><div class="picker" id="agentPick"></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>
|
|
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>
|
|
679
|
+
<div id="modelWrap"></div>
|
|
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>
|
|
681
|
+
<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) => {
|
|
682
|
+
const paint = () => {
|
|
683
|
+
$('#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('');
|
|
684
|
+
$('#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>';
|
|
685
|
+
const ag = agents.find((a) => a.id === sel.agent);
|
|
686
|
+
$('#modeWrap', sh).hidden = !!ag?.noModes;
|
|
687
|
+
$$('#modePick button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.mode === sel.mode));
|
|
688
|
+
$('#modeHelp', sh).textContent = modeHelp(sel.agent, sel.mode);
|
|
689
|
+
};
|
|
690
|
+
const paintModel = async () => {
|
|
691
|
+
const w = $('#modelWrap', sh);
|
|
692
|
+
if (sel.agent === 'ollama') {
|
|
693
|
+
const models = await api('/api/ollama/models').catch(() => []);
|
|
694
|
+
if (!sel.model) sel.model = models[0]?.name || '';
|
|
695
|
+
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>`;
|
|
696
|
+
$('#modelSel', sh).onchange = (e) => (sel.model = e.target.value);
|
|
697
|
+
} else if (sel.agent === 'demo') w.innerHTML = '';
|
|
698
|
+
else {
|
|
699
|
+
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>';
|
|
700
|
+
$('#modelIn', sh).value = sel.model; $('#modelIn', sh).oninput = (e) => (sel.model = e.target.value.trim());
|
|
701
|
+
}
|
|
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
|
+
};
|
|
728
|
+
sh.onclick = (e) => {
|
|
729
|
+
const rp = e.target.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
|
|
730
|
+
if (e.target.closest('[data-close]')) return closeSheet();
|
|
731
|
+
const ag = e.target.closest('[data-agent-id]');
|
|
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(); }
|
|
733
|
+
const pj = e.target.closest('[data-path]'); if (pj) { sel.cwd = pj.dataset.path; paint(); }
|
|
734
|
+
const mo = e.target.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; paint(); }
|
|
735
|
+
};
|
|
736
|
+
paint(); paintModel();
|
|
737
|
+
$('#startBtn', sh).onclick = async () => {
|
|
738
|
+
const text = $('#firstMsg', sh).value.trim();
|
|
739
|
+
if (!text) { $('#firstMsg', sh).focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
740
|
+
$('#startBtn', sh).disabled = true;
|
|
741
|
+
try { const s = await api('/api/sessions', { method: 'POST', body: { ...sel, text } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
|
|
742
|
+
catch (e) { toast(e.message, 4200); $('#startBtn', sh).disabled = false; }
|
|
743
|
+
};
|
|
744
|
+
if (!isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 80);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
function modeSheet(s) {
|
|
748
|
+
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) => {
|
|
749
|
+
$$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
|
|
750
|
+
try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { mode: b.dataset.m } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); }
|
|
751
|
+
catch (e) { toast(e.message); }
|
|
752
|
+
}));
|
|
753
|
+
});
|
|
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
|
+
|
|
824
|
+
function sessionOptions(id) {
|
|
825
|
+
const s = state.sessions.get(id); if (!s) return;
|
|
826
|
+
openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
|
|
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>
|
|
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>' : ''}
|
|
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) => {
|
|
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(); };
|
|
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'); };
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/* ---------- Ordinador */
|
|
837
|
+
function viewMachine() {
|
|
838
|
+
const m = machine(), info = state.info, ms = store.machines;
|
|
839
|
+
if (!m) return viewConnect();
|
|
840
|
+
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>
|
|
841
|
+
<div class="grid2">
|
|
842
|
+
<div class="stack">
|
|
843
|
+
<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>
|
|
844
|
+
${info?.roots?.length ? `<section class="panel"><header><h2>Carpetes compartides</h2></header><div class="body mono small">${info.roots.map(esc).join('<br>')}</div></section>` : ''}
|
|
845
|
+
</div>
|
|
846
|
+
<div class="stack">
|
|
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>' : ''}
|
|
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>
|
|
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('')}
|
|
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>
|
|
852
|
+
</div>
|
|
853
|
+
</div>`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/* ---------- Models d'Ollama */
|
|
857
|
+
const OLLAMA_PICKS = [
|
|
858
|
+
{ name: 'qwen3:4b', size: '2,5 GB', note: 'Ràpid i bo per a gairebé tot.' },
|
|
859
|
+
{ name: 'qwen3:8b', size: '5,2 GB', note: 'Més capaç i encara àgil.' },
|
|
860
|
+
{ name: 'qwen2.5-coder:7b', size: '4,7 GB', note: 'Pensat per escriure i revisar codi.' },
|
|
861
|
+
{ name: 'llama3.2:3b', size: '2,0 GB', note: 'Lleuger, per a ordinadors modestos.' },
|
|
862
|
+
{ name: 'gemma3:4b', size: '3,3 GB', note: 'Va bé amb textos i resums.' },
|
|
863
|
+
{ name: 'deepseek-r1:8b', size: '5,2 GB', note: 'Raona pas a pas abans de respondre.' },
|
|
864
|
+
{ name: 'mistral:7b', size: '4,1 GB', note: 'Clàssic i equilibrat.' }
|
|
865
|
+
];
|
|
866
|
+
async function loadOllama(force) {
|
|
867
|
+
if (state.ollama && !force) return paintModels();
|
|
868
|
+
try { state.ollama = await api('/api/ollama'); }
|
|
869
|
+
catch (e) { state.ollama = { available: false, models: [], running: [], pulling: [], error: e.message }; }
|
|
870
|
+
paintModels();
|
|
871
|
+
}
|
|
872
|
+
function viewModels() {
|
|
873
|
+
return `<div class="top"><div><h1>Models</h1><p>Models d’Ollama que funcionen al teu ordinador, sense internet i sense comptes.</p></div><button class="btn" data-act="ollama-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div><div id="modelsBody">${state.ollama ? '' : `<div class="panel empty">${bot('work')}<h2>Consultant Ollama…</h2></div>`}</div>`;
|
|
874
|
+
}
|
|
875
|
+
function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
|
|
876
|
+
function paintModels() {
|
|
877
|
+
const box = $('#modelsBody'); if (!box || !state.ollama) return;
|
|
878
|
+
const o = state.ollama, have = new Set(o.models.map((m) => m.name));
|
|
879
|
+
if (!o.available) {
|
|
880
|
+
box.innerHTML = `<div class="panel empty">${bot('sleep')}<h2>Ollama no està engegat en aquest ordinador</h2><p>Instal·la’l des d’<a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a> i engega’l amb <span class="mono">ollama serve</span>. Després, torna-ho a comprovar.</p><button class="btn" data-act="ollama-refresh" type="button">${svg(ICON.refresh)}Torna-ho a comprovar</button></div>`;
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const pulling = o.pulling || [];
|
|
884
|
+
box.innerHTML = `<div class="grid2">
|
|
885
|
+
<div class="stack">
|
|
886
|
+
<section class="panel"><header><h2>Instal·lats</h2><span class="muted small">${o.models.length}</span></header><div class="body">${o.models.length ? o.models.map((m) => `
|
|
887
|
+
<div class="agent-row"><span class="grow"><div class="row"><b class="mono" style="font-size:14px">${esc(m.name)}</b>${(o.running || []).includes(m.name) ? '<span class="chip waiting">A memòria</span>' : ''}</div><div class="muted small">${[m.params, m.quant, bytes(m.size)].filter(Boolean).map(esc).join(' · ')}</div></span>
|
|
888
|
+
<button class="btn sm" data-act="ollama-chat" data-name="${esc(m.name)}" type="button">Sessió</button><button class="icon-btn" data-act="ollama-del" data-name="${esc(m.name)}" type="button" aria-label="Esborra ${esc(m.name)}">${svg(ICON.trash)}</button></div>`).join('') : '<p class="muted small">Encara no n’hi ha cap. Tria’n un de la llista o escriu-ne el nom.</p>'}</div></section>
|
|
889
|
+
${pulling.length ? `<section class="panel"><header><h2>Descarregant</h2></header><div class="body stack">${pulling.map((j) => `
|
|
890
|
+
<div class="stack" data-pull="${esc(j.name)}"><div class="row between"><b class="mono" style="font-size:14px">${esc(j.name)}</b><button class="btn sm ghost" data-act="ollama-cancel" data-name="${esc(j.name)}" type="button">Cancel·la</button></div><div class="progress"><i style="width:${pctOf(j)}%"></i></div><div class="muted small mono" data-st>${esc(j.status)}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}</div></div>`).join('')}</div></section>` : ''}
|
|
891
|
+
</div>
|
|
892
|
+
<div class="stack">
|
|
893
|
+
<section class="panel"><header><h2>Descarrega un model</h2></header><div class="body stack">
|
|
894
|
+
<form class="row" id="pullForm" style="flex-wrap:nowrap"><input type="text" id="pullIn" placeholder="Nom del model, p. ex. qwen3:4b" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">${svg(ICON.download)}Descarrega</button></form>
|
|
895
|
+
<p class="muted small">Tens tots els noms a <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>. Les mides són aproximades.</p>
|
|
896
|
+
<div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `
|
|
897
|
+
<button class="card" type="button" ${got || busy ? 'disabled' : `data-act="ollama-pull" data-name="${p.name}"`} style="${got ? 'opacity:.6' : ''}"><div class="row between"><b class="mono" style="font-size:14px">${p.name}</b><span class="muted small">${got ? 'Instal·lat' : busy ? 'Descarregant…' : p.size}</span></div><div class="muted small" style="margin-top:4px">${p.note}</div></button>`; }).join('')}</div>
|
|
898
|
+
</div></section>
|
|
899
|
+
</div>
|
|
900
|
+
</div>`;
|
|
901
|
+
}
|
|
902
|
+
const pullDone = new Set(); // descàrregues que han acabat abans que respongués la petició
|
|
903
|
+
async function pullModel(name) {
|
|
904
|
+
name = String(name || '').trim(); pullDone.delete(name);
|
|
905
|
+
if (!name) { $('#pullIn')?.focus(); return toast('Escriu el nom del model'); }
|
|
906
|
+
try {
|
|
907
|
+
await api('/api/ollama/pull', { method: 'POST', body: { name } });
|
|
908
|
+
if (pullDone.has(name)) { pullDone.delete(name); return; }
|
|
909
|
+
state.ollama = state.ollama || { available: true, models: [], running: [], pulling: [] };
|
|
910
|
+
if (!state.ollama.pulling.some((j) => j.name === name)) state.ollama.pulling.push({ name, status: 'Començant…', completed: 0, total: 0 });
|
|
911
|
+
paintModels(); toast(`Descarregant ${name}`);
|
|
912
|
+
} catch (e) { toast(e.message, 4200); }
|
|
913
|
+
}
|
|
914
|
+
async function deleteModel(name) {
|
|
915
|
+
if (!confirm(`S’esborrarà ${name} de l’ordinador. El podràs tornar a descarregar.`)) return;
|
|
916
|
+
try { await api(`/api/ollama/models/${encodeURIComponent(name)}`, { method: 'DELETE' }); toast('Model esborrat'); loadOllama(true); }
|
|
917
|
+
catch (e) { toast(e.message, 4200); }
|
|
918
|
+
}
|
|
919
|
+
function onOllama(msg) {
|
|
920
|
+
if (!state.ollama) state.ollama = { available: true, models: [], running: [], pulling: [] };
|
|
921
|
+
const list = state.ollama.pulling || (state.ollama.pulling = []);
|
|
922
|
+
let j = list.find((x) => x.name === msg.name);
|
|
923
|
+
if (msg.done) {
|
|
924
|
+
pullDone.add(msg.name); setTimeout(() => pullDone.delete(msg.name), 5000);
|
|
925
|
+
state.ollama.pulling = list.filter((x) => x.name !== msg.name);
|
|
926
|
+
toast(msg.error ? `${msg.name}: ${msg.error}` : `${msg.name} ja està llest`, 4200);
|
|
927
|
+
if (!msg.error) api('/api/info?refresh=1').then((i) => (state.info = i)).catch(() => {});
|
|
928
|
+
if (state.route.name === 'models') loadOllama(true);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (!j) { j = { name: msg.name }; list.push(j); }
|
|
932
|
+
Object.assign(j, { status: msg.status, completed: msg.completed, total: msg.total });
|
|
933
|
+
const row = $(`[data-pull="${CSS.escape(msg.name)}"]`);
|
|
934
|
+
if (row) { $('.progress > i', row).style.width = pctOf(j) + '%'; $('[data-st]', row).textContent = `${j.status}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}`; }
|
|
935
|
+
else if (state.route.name === 'models') paintModels();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/* ---------- Models per a la pissarra */
|
|
939
|
+
window.NioBrains = {
|
|
940
|
+
list() {
|
|
941
|
+
const out = [{ id: 'rules', label: 'Ordres ràpides' }];
|
|
942
|
+
if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}${isDemo() ? ' (simulat)' : ''}` }));
|
|
943
|
+
if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
|
|
944
|
+
return out;
|
|
945
|
+
},
|
|
946
|
+
async ask(id, messages) {
|
|
947
|
+
if (id.startsWith('ollama:')) {
|
|
948
|
+
if (!machine()) throw new Error('No hi ha cap ordinador connectat');
|
|
949
|
+
return (await api('/api/ollama/chat', { method: 'POST', body: { model: id.slice(7), messages, format: 'json' } })).content;
|
|
950
|
+
}
|
|
951
|
+
if (id === 'local') {
|
|
952
|
+
if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
|
|
953
|
+
const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
|
|
954
|
+
return r.choices[0]?.message?.content || '';
|
|
955
|
+
}
|
|
956
|
+
throw new Error('Aquest model ja no està disponible');
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
async function brainsForBoard() {
|
|
960
|
+
if (state.route.name !== 'pissarra') return;
|
|
961
|
+
if (machine() && state.connected && !state.ollama) { try { state.ollama = await api('/api/ollama'); } catch { /* sense Ollama */ } }
|
|
962
|
+
window.NioBoard?.refreshBrains();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/* ---------- Avisos */
|
|
966
|
+
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)); }
|
|
967
|
+
async function enablePush() {
|
|
968
|
+
try {
|
|
969
|
+
if (isDemo()) throw new Error('Els avisos només funcionen amb un ordinador de veritat');
|
|
970
|
+
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');
|
|
971
|
+
if ((await Notification.requestPermission()) !== 'granted') throw new Error('Cal que permetis les notificacions');
|
|
972
|
+
const reg = await navigator.serviceWorker.ready;
|
|
973
|
+
const sub = (await reg.pushManager.getSubscription()) || (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64uToU8(state.info.vapidKey) }));
|
|
974
|
+
await api('/api/push', { method: 'POST', body: sub.toJSON() });
|
|
975
|
+
syncServiceWorker(); toast('Avisos activats');
|
|
976
|
+
} catch (e) { toast(e.message, 4200); }
|
|
977
|
+
}
|
|
978
|
+
async function syncServiceWorker() {
|
|
979
|
+
const m = machine(); if (!m || m.url === DEMO_URL || !('serviceWorker' in navigator)) return;
|
|
980
|
+
try { (await navigator.serviceWorker.ready).active?.postMessage({ type: 'nio-cfg', url: m.url, token: m.token }); } catch { /* sense service worker */ }
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/* ---------------------------------------------------------------- xat local (WebLLM) */
|
|
984
|
+
const LOCAL_MODELS = [
|
|
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.' },
|
|
986
|
+
{ id: 'Llama-3.2-1B-Instruct-q4f16_1-MLC', name: 'Llama 3.2 · 1B', size: '≈ 0,9 GB', note: 'Petit i equilibrat.' },
|
|
987
|
+
{ id: 'Qwen3.5-2B-q4f16_1-MLC', name: 'Qwen 3.5 · 2B', size: '≈ 1,5 GB', note: 'Més capaç. Millor en mòbils recents.' },
|
|
988
|
+
{ id: 'Qwen3-1.7B-q4f16_1-MLC', name: 'Qwen 3 · 1,7B', size: '≈ 1,3 GB', note: 'Va bé amb codi i raonaments curts.' },
|
|
989
|
+
{ id: 'Phi-3.5-mini-instruct-q4f16_1-MLC', name: 'Phi 3.5 mini · 3,8B', size: '≈ 2,2 GB', note: 'El més potent. Millor en tauleta o ordinador.' }
|
|
990
|
+
];
|
|
991
|
+
const local = { engine: null, model: null, loading: false, progress: 0, status: '', messages: [], busy: false };
|
|
992
|
+
const stripThink = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trimStart();
|
|
993
|
+
function viewLocal() {
|
|
994
|
+
const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
|
|
995
|
+
if (!local.engine) {
|
|
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>
|
|
997
|
+
<div class="stack-lg" style="max-width:640px">
|
|
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>'}
|
|
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>
|
|
1000
|
+
<a class="card" href="#/models"><b>Tens Ollama a l’ordinador?</b><div class="muted small" style="margin-top:4px">Els models d’Ollama són més grans i capaços. Funcionen a l’ordinador i els fas servir des d’aquí.</div></a>
|
|
1001
|
+
${local.loading ? `<div class="stack"><div class="progress"><i style="width:${Math.round(local.progress * 100)}%"></i></div><p class="muted small mono" id="lmStatus">${esc(local.status)}</p></div>` : `<div class="stack"><button class="btn ink block" data-act="lm-load" type="button" ${hasGpu ? '' : 'disabled'}>Descarrega i obre el model</button><p class="muted small">La primera vegada es descarrega i queda desat al navegador.</p></div>`}
|
|
1002
|
+
</div>`;
|
|
1003
|
+
}
|
|
1004
|
+
const info = LOCAL_MODELS.find((m) => m.id === local.model);
|
|
1005
|
+
return `<section class="chat">
|
|
1006
|
+
<div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub">Model dins d’aquest dispositiu</div></div></div>
|
|
1007
|
+
<div class="mode-bar"><span class="chip"><span class="agent-tag" data-agent="browser">${esc(info?.name || local.model)}</span></span><span class="chip">Al dispositiu</span><button class="chip" type="button" data-act="lm-change">Canvia de model</button><button class="chip" type="button" data-act="lm-clear">Comença de nou</button></div>
|
|
1008
|
+
<div class="feed" id="lfeed">${local.messages.length ? '' : `<div class="empty">${bot('idle')}<h2>Pregunta el que vulguis</h2><p>Va bé per resumir, reescriure textos curts o buscar idees.</p></div>`}</div>
|
|
1009
|
+
<div class="composer"><div class="inner"><textarea rows="1" id="lmsg" placeholder="Escriu al model…" enterkeyhint="send" aria-label="Missatge per al model"></textarea><button class="send" id="lsend" type="button" aria-label="Envia">${svg(ICON.send)}</button></div></div>
|
|
1010
|
+
</section>`;
|
|
1011
|
+
}
|
|
1012
|
+
function bindLocal() {
|
|
1013
|
+
const feed = $('#lfeed'); if (!feed) return;
|
|
1014
|
+
for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
|
|
1015
|
+
feed.scrollTop = feed.scrollHeight;
|
|
1016
|
+
const ta = $('#lmsg');
|
|
1017
|
+
ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; });
|
|
1018
|
+
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
|
|
1019
|
+
$('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
|
|
1020
|
+
setLocalBusy(local.busy);
|
|
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(' ');
|
|
1035
|
+
async function loadLocal(id) {
|
|
1036
|
+
local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
|
|
1037
|
+
try {
|
|
1038
|
+
const webllm = await import('https://esm.run/@mlc-ai/web-llm@0.2.86');
|
|
1039
|
+
local.engine = await webllm.CreateMLCEngine(id, { initProgressCallback: (p) => {
|
|
1040
|
+
local.progress = p.progress || 0; local.status = p.text || '';
|
|
1041
|
+
const bar = $('.progress > i'), txt = $('#lmStatus');
|
|
1042
|
+
if (bar) bar.style.width = `${Math.round(local.progress * 100)}%`; if (txt) txt.textContent = local.status;
|
|
1043
|
+
} });
|
|
1044
|
+
local.model = id; window.NioBoard?.refreshBrains();
|
|
1045
|
+
} catch (e) { local.engine = null; toast(`No s’ha pogut obrir el model: ${e.message}`, 5000); }
|
|
1046
|
+
finally { local.loading = false; if (state.route.name === 'mobil') render(); }
|
|
1047
|
+
}
|
|
1048
|
+
async function sendLocal() {
|
|
1049
|
+
const ta = $('#lmsg'), text = ta.value.trim();
|
|
1050
|
+
if (!text || local.busy) return;
|
|
1051
|
+
ta.value = ''; ta.style.height = 'auto';
|
|
1052
|
+
const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
|
|
1053
|
+
local.messages.push({ role: 'user', content: text });
|
|
1054
|
+
feed.appendChild(el(`<div class="msg-user">${esc(text)}</div>`));
|
|
1055
|
+
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); feed.scrollTop = feed.scrollHeight;
|
|
1056
|
+
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1057
|
+
try {
|
|
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);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/* ---------------------------------------------------------------- accions */
|
|
1074
|
+
document.addEventListener('click', async (e) => {
|
|
1075
|
+
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-lm]');
|
|
1076
|
+
if (!a) return;
|
|
1077
|
+
if (a.dataset.copy) { try { await navigator.clipboard.writeText(a.dataset.copy); toast('Copiat'); } catch { toast(a.dataset.copy); } return; }
|
|
1078
|
+
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
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(); }
|
|
1080
|
+
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
1081
|
+
switch (a.dataset.act) {
|
|
1082
|
+
case 'new': return newSessionSheet();
|
|
1083
|
+
case 'ollama-refresh': state.ollama = null; return loadOllama(true);
|
|
1084
|
+
case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
|
|
1085
|
+
case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
|
|
1086
|
+
case 'ollama-del': return deleteModel(a.dataset.name);
|
|
1087
|
+
case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
|
|
1088
|
+
case 'demo': return tryDemo();
|
|
1089
|
+
case 'retry': return connect();
|
|
1090
|
+
case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
|
|
1091
|
+
case 'session-more': return sessionOptions(state.route.id);
|
|
1092
|
+
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
1093
|
+
case 'push': return enablePush();
|
|
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;
|
|
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);
|
|
1100
|
+
case 'forget': {
|
|
1101
|
+
if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
|
|
1102
|
+
const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
|
|
1103
|
+
streamCtrl?.abort(); unsubDemo?.(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
|
|
1104
|
+
connect(); return render();
|
|
1105
|
+
}
|
|
1106
|
+
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
1107
|
+
case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
|
|
1108
|
+
case 'lm-clear': local.messages = []; return render();
|
|
1109
|
+
case 'palette': return palette();
|
|
1110
|
+
case 'tour': return tourStart();
|
|
1111
|
+
}
|
|
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(); } });
|
|
1114
|
+
document.addEventListener('submit', (e) => {
|
|
1115
|
+
if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
|
|
1116
|
+
if (e.target.id !== 'pairForm') return;
|
|
1117
|
+
e.preventDefault();
|
|
1118
|
+
const pair = parsePair($('#pairInput', e.target).value);
|
|
1119
|
+
if (!pair) return toast('Aquest enllaç no és de connexió');
|
|
1120
|
+
closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
/* ---------------------------------------------------------------- paleta d'ordres */
|
|
1124
|
+
let palSel = 0;
|
|
1125
|
+
function commands() {
|
|
1126
|
+
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1127
|
+
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1128
|
+
[...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}`) }));
|
|
1129
|
+
if (!isDemo()) c.push({ label: 'Prova l’ordinador simulat', hint: 'Prova', run: tryDemo });
|
|
1130
|
+
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1131
|
+
return c;
|
|
1132
|
+
}
|
|
1133
|
+
function palette() { const d = $('#palette'); $('#palQ').value = ''; palSel = 0; drawPal(); d.showModal(); if (T.on) $('#tour').hidden = true; $('#palQ').focus(); }
|
|
1134
|
+
function drawPal() {
|
|
1135
|
+
const q = $('#palQ').value.trim().toLowerCase(), list = commands().filter((c) => !q || c.label.toLowerCase().includes(q));
|
|
1136
|
+
palSel = Math.min(palSel, Math.max(0, list.length - 1));
|
|
1137
|
+
$('#palList').innerHTML = list.length ? list.map((c, i) => `<li role="option" data-i="${i}" aria-selected="${i === palSel}">${esc(c.label)}<span>${esc(c.hint)}</span></li>`).join('') : '<li aria-disabled="true">Cap resultat</li>';
|
|
1138
|
+
$('#palList')._list = list;
|
|
1139
|
+
}
|
|
1140
|
+
function palRun(i) { const c = $('#palList')._list?.[i]; if (!c) return; $('#palette').close(); c.run(); }
|
|
1141
|
+
$('#palQ').addEventListener('input', () => { palSel = 0; drawPal(); });
|
|
1142
|
+
$('#palQ').addEventListener('keydown', (e) => {
|
|
1143
|
+
const n = $('#palList')._list?.length || 0;
|
|
1144
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); palSel = (palSel + 1) % Math.max(n, 1); drawPal(); }
|
|
1145
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); palSel = (palSel - 1 + n) % Math.max(n, 1); drawPal(); }
|
|
1146
|
+
else if (e.key === 'Enter') { e.preventDefault(); palRun(palSel); }
|
|
1147
|
+
});
|
|
1148
|
+
$('#palList').addEventListener('click', (e) => { const li = e.target.closest('li[data-i]'); if (li) palRun(+li.dataset.i); });
|
|
1149
|
+
$('#palette').addEventListener('click', (e) => { if (e.target.id === 'palette') e.target.close(); });
|
|
1150
|
+
addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); $('#palette').open ? $('#palette').close() : palette(); } });
|
|
1151
|
+
|
|
1152
|
+
/* ---------------------------------------------------------------- menú mòbil */
|
|
1153
|
+
const mq = matchMedia('(max-width: 820px)');
|
|
1154
|
+
let tourT = 0;
|
|
1155
|
+
function setCollapsed(on, instant) {
|
|
1156
|
+
const body = document.body;
|
|
1157
|
+
body.classList.toggle('side-collapsed', on); LS.set('nio.side', on ? 'plegat' : 'obert');
|
|
1158
|
+
const b = $('#collapse'); b.setAttribute('aria-label', on ? 'Desplega el menú' : 'Plega el menú'); b.setAttribute('aria-expanded', String(!on)); b.title = `${on ? 'Desplega' : 'Plega'} el menú (Ctrl+B)`;
|
|
1159
|
+
window.NioBot?.refresh();
|
|
1160
|
+
clearTimeout(tourT);
|
|
1161
|
+
if (!instant) tourT = setTimeout(() => tourPlace(false), reduce ? 0 : 360);
|
|
1162
|
+
}
|
|
1163
|
+
$('#collapse').addEventListener('click', () => setCollapsed(!document.body.classList.contains('side-collapsed')));
|
|
1164
|
+
addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b' && !mq.matches && !e.target.closest?.('input, textarea')) { e.preventDefault(); setCollapsed(!document.body.classList.contains('side-collapsed')); } });
|
|
1165
|
+
setCollapsed(LS.get('nio.side', 'obert') === 'plegat', true);
|
|
1166
|
+
function setNav(open) {
|
|
1167
|
+
open = mq.matches && open;
|
|
1168
|
+
document.body.classList.toggle('nav-open', open);
|
|
1169
|
+
$('#burger').setAttribute('aria-expanded', open); $('#burger').setAttribute('aria-label', open ? 'Tanca el menú' : 'Obre el menú');
|
|
1170
|
+
$('#side').inert = mq.matches && !open; $('#main').inert = open;
|
|
1171
|
+
}
|
|
1172
|
+
$('#burger').addEventListener('click', () => setNav(!document.body.classList.contains('nav-open')));
|
|
1173
|
+
$('#scrim').addEventListener('click', () => setNav(false));
|
|
1174
|
+
$('#side').addEventListener('click', (e) => { if (e.target.closest('a, button')) setNav(false); });
|
|
1175
|
+
addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
|
|
1176
|
+
mq.addEventListener('change', () => setNav(false));
|
|
1177
|
+
(() => {
|
|
1178
|
+
const side = $('#side'); let x0 = null, y0 = 0, dx = 0, mode = null;
|
|
1179
|
+
addEventListener('touchstart', (e) => {
|
|
1180
|
+
if (!mq.matches || $('#sheet').open || state.route.name === 'pissarra') return;
|
|
1181
|
+
const t = e.touches[0], open = document.body.classList.contains('nav-open');
|
|
1182
|
+
if (open || t.clientX < 22) { x0 = t.clientX; y0 = t.clientY; dx = 0; mode = open ? 'close' : 'open'; }
|
|
1183
|
+
}, { passive: true });
|
|
1184
|
+
addEventListener('touchmove', (e) => {
|
|
1185
|
+
if (x0 == null) return;
|
|
1186
|
+
const t = e.touches[0]; dx = t.clientX - x0;
|
|
1187
|
+
if (Math.abs(t.clientY - y0) > Math.abs(dx) && Math.abs(dx) < 12) { x0 = null; return; }
|
|
1188
|
+
const w = side.offsetWidth, pos = mode === 'open' ? Math.min(0, -w + Math.max(0, dx)) : Math.min(0, dx);
|
|
1189
|
+
side.classList.add('dragging'); side.style.transform = `translateX(${pos}px)`;
|
|
1190
|
+
}, { passive: true });
|
|
1191
|
+
addEventListener('touchend', () => {
|
|
1192
|
+
if (x0 == null) return;
|
|
1193
|
+
side.classList.remove('dragging'); side.style.transform = '';
|
|
1194
|
+
if (mode === 'open' && dx > 60) setNav(true); else if (mode === 'close' && dx < -60) setNav(false);
|
|
1195
|
+
x0 = null;
|
|
1196
|
+
});
|
|
1197
|
+
})();
|
|
1198
|
+
|
|
1199
|
+
/* ---------------------------------------------------------------- tutorial */
|
|
1200
|
+
const TOUR_KEY = 'nio.tour.v2';
|
|
1201
|
+
const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
|
|
1202
|
+
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
1203
|
+
const TSTEPS = [
|
|
1204
|
+
{ 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' },
|
|
1205
|
+
{ 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.') },
|
|
1206
|
+
{ 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.' },
|
|
1207
|
+
{ 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.' },
|
|
1208
|
+
{ 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.' },
|
|
1209
|
+
{ 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.' },
|
|
1210
|
+
{ route: 'pissarra', target: '.side nav a[href="#/pissarra"]', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama o amb el model del mòbil.' },
|
|
1211
|
+
{ route: 'maquina', target: '.side nav a[href="#/ordinador"]', m: '.page .top h1, .connect-head h1', title: 'El teu ordinador', text: 'Aquí veus quins agents hi ha instal·lats, actives els avisos i connectes més ordinadors.' },
|
|
1212
|
+
{ target: '[data-act=palette]', m: '#burger', title: () => (mq.matches ? 'El menú' : 'Un últim truc'), text: () => (mq.matches ? 'Toca aquí per anar a qualsevol secció. També pots lliscar des de la vora esquerra de la pantalla.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
|
|
1213
|
+
{ center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú.', next: 'Finalitza' }
|
|
1214
|
+
];
|
|
1215
|
+
const tval = (v) => (typeof v === 'function' ? v() : v);
|
|
1216
|
+
function tourStart() { T.on = true; T.seen = true; tourGo(0); }
|
|
1217
|
+
function tourEnd(silent) { T.on = false; T.seen = true; saveTour(); $('#tour').hidden = true; if (!silent) toast('Tutorial tancat. El tens al menú quan vulguis.'); }
|
|
1218
|
+
function tourGo(i) {
|
|
1219
|
+
while (i < TSTEPS.length && TSTEPS[i].skipIf?.()) i++;
|
|
1220
|
+
if (i >= TSTEPS.length) return tourEnd(true);
|
|
1221
|
+
T.i = Math.max(0, i); saveTour();
|
|
1222
|
+
const st = TSTEPS[T.i];
|
|
1223
|
+
if (st.route && state.route.name !== st.route) { setTimeout(() => tourDraw(true), 80); go(HASH[st.route]); }
|
|
1224
|
+
else tourDraw(true);
|
|
1225
|
+
}
|
|
1226
|
+
function tourDraw(scroll) {
|
|
1227
|
+
if (!T.on) return;
|
|
1228
|
+
const st = TSTEPS[T.i], card = $('#tourCard'), busy = $('#sheet').open || $('#palette').open;
|
|
1229
|
+
$('#tour').hidden = busy; if (busy) return;
|
|
1230
|
+
const n = TSTEPS.length - 2;
|
|
1231
|
+
card.className = 'tour-card' + (st.center ? ' center' : '');
|
|
1232
|
+
card.innerHTML = `<div class="tour-head">${bot(st.mood || 'idle')}${st.center ? '' : `<span class="tour-step">Pas ${T.i} de ${n}</span>`}<button class="tour-x" data-tour="close" type="button" aria-label="Tanca el tutorial">×</button></div>
|
|
1233
|
+
<h3 id="tourT">${tval(st.title)}</h3><p>${tval(st.text)}</p>
|
|
1234
|
+
<div class="tour-foot">${st.center ? '' : `<div>${T.i > 1 ? '<button class="btn sm ghost" data-tour="back" type="button">Enrere</button>' : ''}</div>`}
|
|
1235
|
+
<div class="row">${T.i === 0 ? '<button class="btn sm ghost" data-tour="close" type="button">Ara no</button>' : ''}<button class="btn sm ink" data-tour="next" type="button">${tval(st.next) || 'Següent'}</button></div></div>`;
|
|
1236
|
+
window.NioBot?.refresh(); tourPlace(scroll);
|
|
1237
|
+
}
|
|
1238
|
+
function tourTarget() {
|
|
1239
|
+
const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target), e = sel && document.querySelector(sel);
|
|
1240
|
+
if (!e || !e.getClientRects().length || e.closest('[inert]')) return null;
|
|
1241
|
+
return e;
|
|
1242
|
+
}
|
|
1243
|
+
function tourPlace(scroll) {
|
|
1244
|
+
if (!T.on || $('#tour').hidden) return;
|
|
1245
|
+
const spot = $('#tourSpot'), card = $('#tourCard'), e = tourTarget();
|
|
1246
|
+
if (!e) {
|
|
1247
|
+
Object.assign(spot.style, { left: innerWidth / 2 + 'px', top: innerHeight / 2 + 'px', width: '0px', height: '0px' });
|
|
1248
|
+
card.classList.add('center'); card.classList.remove('top', 'bottom');
|
|
1249
|
+
Object.assign(card.style, { left: Math.max(16, (innerWidth - card.offsetWidth) / 2) + 'px', top: Math.max(16, (innerHeight - card.offsetHeight) / 2) + 'px', bottom: '' });
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
card.classList.remove('center');
|
|
1253
|
+
const pad = 6, gap = 14;
|
|
1254
|
+
if (mq.matches) {
|
|
1255
|
+
// Al mòbil la targeta va a dalt o a baix de la pantalla, sempre a l'altra banda del que assenyala.
|
|
1256
|
+
const ch = card.offsetHeight, mbar = $('.mbar').offsetHeight;
|
|
1257
|
+
let r = e.getBoundingClientRect();
|
|
1258
|
+
const below = r.top + r.height / 2 < innerHeight / 2 || r.top < mbar + ch + 24;
|
|
1259
|
+
const free = below ? [mbar + 8, innerHeight - ch - 24] : [ch + 24, innerHeight - 8];
|
|
1260
|
+
if (scroll && (r.top < free[0] || r.bottom > free[1]) && !e.closest('.mbar, .bd-stage')) {
|
|
1261
|
+
const want = Math.max(free[0], (free[0] + free[1] - r.height) / 2);
|
|
1262
|
+
scrollBy({ top: r.top - want, behavior: 'instant' });
|
|
1263
|
+
r = e.getBoundingClientRect();
|
|
1264
|
+
}
|
|
1265
|
+
Object.assign(spot.style, { left: r.left - pad + 'px', top: r.top - pad + 'px', width: r.width + pad * 2 + 'px', height: r.height + pad * 2 + 'px' });
|
|
1266
|
+
card.classList.toggle('bottom', below); card.classList.toggle('top', !below);
|
|
1267
|
+
Object.assign(card.style, { left: '', top: '', bottom: '' });
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
card.classList.remove('top', 'bottom');
|
|
1271
|
+
if (scroll) { const r0 = e.getBoundingClientRect(); if (r0.top < 70 || r0.bottom > innerHeight - 220) { e.scrollIntoView({ block: 'center', behavior: 'instant' }); } }
|
|
1272
|
+
const r = e.getBoundingClientRect();
|
|
1273
|
+
Object.assign(spot.style, { left: r.left - pad + 'px', top: r.top - pad + 'px', width: r.width + pad * 2 + 'px', height: r.height + pad * 2 + 'px' });
|
|
1274
|
+
const cw = card.offsetWidth, ch = card.offsetHeight;
|
|
1275
|
+
let top = r.bottom + pad + gap, left = r.left + r.width / 2 - cw / 2;
|
|
1276
|
+
if (top + ch > innerHeight - 12) top = r.top - pad - gap - ch;
|
|
1277
|
+
if (r.width < 260 && r.right + gap + cw < innerWidth - 12 && (top < 12 || r.bottom + ch > innerHeight)) { left = r.right + gap; top = r.top + r.height / 2 - ch / 2; }
|
|
1278
|
+
Object.assign(card.style, { left: Math.min(Math.max(12, left), innerWidth - cw - 12) + 'px', top: Math.min(Math.max(12, top), innerHeight - ch - 12) + 'px', bottom: '' });
|
|
1279
|
+
}
|
|
1280
|
+
function tourUpdate() { if (T.on) setTimeout(() => tourDraw(false), 30); }
|
|
1281
|
+
$('#tour').addEventListener('click', (e) => {
|
|
1282
|
+
const b = e.target.closest('[data-tour]'); if (!b) return;
|
|
1283
|
+
if (b.dataset.tour === 'close') tourEnd(false);
|
|
1284
|
+
else if (b.dataset.tour === 'next') tourGo(T.i + 1);
|
|
1285
|
+
else if (b.dataset.tour === 'back') { let i = T.i - 1; while (i > 0 && TSTEPS[i].skipIf?.()) i--; tourGo(i); }
|
|
1286
|
+
});
|
|
1287
|
+
addEventListener('resize', () => tourPlace(false));
|
|
1288
|
+
addEventListener('scroll', () => tourPlace(false), { passive: true });
|
|
1289
|
+
addEventListener('keydown', (e) => {
|
|
1290
|
+
if (!T.on || $('#sheet').open || $('#palette').open || e.target.closest?.('input, textarea')) return;
|
|
1291
|
+
if (e.key === 'Escape') tourEnd(false); else if (e.key === 'ArrowRight') tourGo(T.i + 1); else if (e.key === 'ArrowLeft' && T.i > 1) tourGo(T.i - 1);
|
|
1292
|
+
});
|
|
1293
|
+
$('#sheet').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
|
|
1294
|
+
$('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
|
|
1295
|
+
|
|
1296
|
+
/* ---------------------------------------------------------------- arrencada */
|
|
1297
|
+
// Al mòbil, l'alçada visible real (amb el teclat obert o tancat), perquè el camp de text quedi sempre just a sota.
|
|
1298
|
+
if (window.visualViewport) {
|
|
1299
|
+
const vv = visualViewport, root = document.documentElement;
|
|
1300
|
+
const fit = () => { root.style.setProperty('--vvh', `${vv.height}px`); root.style.setProperty('--vvt', `${vv.offsetTop}px`); document.body.classList.toggle('kb', innerHeight - vv.height > 120); };
|
|
1301
|
+
vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); fit();
|
|
1302
|
+
}
|
|
1303
|
+
if ('serviceWorker' in navigator) {
|
|
1304
|
+
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
1305
|
+
navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
|
|
1306
|
+
}
|
|
1307
|
+
addEventListener('hashchange', route);
|
|
1308
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected && !isDemo()) connect(); });
|
|
1309
|
+
setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
|
|
1310
|
+
const qs = new URLSearchParams(location.search).get('s');
|
|
1311
|
+
if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
|
|
1312
|
+
// Les dades del panell antic (simulació d'agents d'operacions) ja no es fan servir.
|
|
1313
|
+
try { localStorage.removeItem('nio.panell.v1'); localStorage.removeItem('nio.tour.v1'); } catch { /* res */ }
|
|
1314
|
+
setNav(false);
|
|
1315
|
+
if (!checkPairHash()) state.route = parseRoute();
|
|
1316
|
+
if (machine()) connect();
|
|
1317
|
+
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
1318
|
+
if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
|