nio-cat 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nio.js +82 -3
- package/package.json +1 -1
- package/src/server.js +28 -6
- package/web/app/app.js +164 -264
- package/web/app/style.css +39 -1
- package/web/index.html +0 -5
package/bin/nio.js
CHANGED
|
@@ -4,6 +4,7 @@ const os = require('os');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const crypto = require('crypto');
|
|
7
|
+
const net = require('net');
|
|
7
8
|
const { spawn } = require('child_process');
|
|
8
9
|
const config = require('../src/config');
|
|
9
10
|
const { Hub } = require('../src/hub');
|
|
@@ -21,11 +22,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
21
22
|
if (a.startsWith('--')) {
|
|
22
23
|
const [k, v] = a.slice(2).split('=');
|
|
23
24
|
if (v !== undefined) flags[k] = v;
|
|
24
|
-
else if (['port', 'url', 'app'].includes(k)) flags[k] = argv[++i];
|
|
25
|
+
else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
|
|
25
26
|
else flags[k] = true;
|
|
26
27
|
} else pos.push(a);
|
|
27
28
|
}
|
|
28
|
-
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help'].includes(pos[0]) ? pos.shift() : 'start';
|
|
29
|
+
const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
|
|
29
30
|
|
|
30
31
|
const c = {
|
|
31
32
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -97,6 +98,76 @@ function showPair(publicUrl) {
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
|
|
102
|
+
// nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
|
|
103
|
+
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
104
|
+
function remoteScript(port, dir) {
|
|
105
|
+
return `set -e
|
|
106
|
+
command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
|
|
107
|
+
up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
|
|
108
|
+
if ! up; then
|
|
109
|
+
mkdir -p "$HOME/.nio"
|
|
110
|
+
if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
|
|
111
|
+
$S nohup npx --yes nio-cat@latest --no-open --no-tunnel --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
|
|
112
|
+
for i in $(seq 1 80); do up && break; sleep 0.5; done
|
|
113
|
+
up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
|
|
114
|
+
fi
|
|
115
|
+
node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
function sshBase(target) {
|
|
119
|
+
const a = ['-o', 'ServerAliveInterval=30'];
|
|
120
|
+
if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
|
|
121
|
+
return a;
|
|
122
|
+
}
|
|
123
|
+
function waitPort(port, ms = 15000) {
|
|
124
|
+
const end = Date.now() + ms;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const t = () => {
|
|
127
|
+
const s = net.connect(port, '127.0.0.1');
|
|
128
|
+
s.once('connect', () => { s.destroy(); resolve(true); });
|
|
129
|
+
s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
|
|
130
|
+
};
|
|
131
|
+
t();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function sshStart() {
|
|
135
|
+
const target = pos[0];
|
|
136
|
+
if (!target || !/^[A-Za-z0-9_][\w.@:\-\[\]]*$/.test(target)) {
|
|
137
|
+
console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--local-port 7718] [--dir /ruta/al/servidor]')}\n`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const rport = Number(flags.port || 7717), lport = Number(flags['local-port'] || 7718);
|
|
141
|
+
console.log(`\n ${c.b('nio')} ${c.dim('· connectant amb')} ${target}${c.dim('…')}`);
|
|
142
|
+
const out = await new Promise((resolve) => {
|
|
143
|
+
const p = spawn('ssh', [...sshBase(target), '-T', '--', target, 'bash -l -s'], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
144
|
+
let buf = '';
|
|
145
|
+
p.stdout.on('data', (d) => (buf += d));
|
|
146
|
+
p.on('error', () => resolve({ code: -1, buf }));
|
|
147
|
+
p.on('close', (code) => resolve({ code, buf }));
|
|
148
|
+
p.stdin.end(remoteScript(rport, flags.dir));
|
|
149
|
+
});
|
|
150
|
+
if (out.code === -1) { console.error(c.warn('\n No trobo l’ordre ssh en aquest ordinador.\n')); process.exit(1); }
|
|
151
|
+
if (out.code !== 0 && !out.buf.includes('NIO_TOKEN=')) { console.error(c.warn('\n No he pogut entrar al servidor per SSH.\n')); process.exit(1); }
|
|
152
|
+
if (out.buf.includes('NIO_NO_NODE')) { console.error(c.warn('\n Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.\n')); process.exit(1); }
|
|
153
|
+
if (out.buf.includes('NIO_NO_START')) { console.error(c.warn('\n nio no s’ha engegat al servidor:\n') + out.buf.replace('NIO_NO_START', '').trim() + '\n'); process.exit(1); }
|
|
154
|
+
const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
|
|
155
|
+
if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
|
|
156
|
+
|
|
157
|
+
const tun = spawn('ssh', [...sshBase(target), '-N', '-T', '-o', 'ExitOnForwardFailure=yes', '-L', `127.0.0.1:${lport}:127.0.0.1:${rport}`, '--', target], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
158
|
+
tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
|
|
159
|
+
const bye = () => { tun.kill(); process.exit(0); };
|
|
160
|
+
process.on('SIGINT', bye);
|
|
161
|
+
process.on('SIGTERM', bye);
|
|
162
|
+
if (!(await waitPort(lport))) { console.error(c.warn(`\n No puc obrir el port ${lport} en aquest ordinador. Prova amb --local-port 7719.\n`)); tun.kill(); process.exit(1); }
|
|
163
|
+
|
|
164
|
+
const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
|
|
165
|
+
console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
|
|
166
|
+
if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
|
|
167
|
+
else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
|
|
168
|
+
console.log(c.dim('\n Deixa aquesta finestra oberta mentre facis servir el servidor. Amb Ctrl+C es tanca el túnel (nio continua al servidor).\n'));
|
|
169
|
+
}
|
|
170
|
+
|
|
100
171
|
async function start() {
|
|
101
172
|
if (flags.port) cfg.port = Number(flags.port);
|
|
102
173
|
if (flags.app) cfg.app = flags.app;
|
|
@@ -178,6 +249,9 @@ async function start() {
|
|
|
178
249
|
|
|
179
250
|
(async () => {
|
|
180
251
|
switch (cmd) {
|
|
252
|
+
case 'ssh':
|
|
253
|
+
await sshStart();
|
|
254
|
+
break;
|
|
181
255
|
case 'doctor':
|
|
182
256
|
await doctor();
|
|
183
257
|
break;
|
|
@@ -207,7 +281,12 @@ async function start() {
|
|
|
207
281
|
--open Obre al navegador la pàgina del codi QR
|
|
208
282
|
nio pair Mostra el codi QR una altra vegada
|
|
209
283
|
|
|
210
|
-
|
|
284
|
+
nio ssh usuari@servidor Fes servir nio en un servidor (VPS) per SSH, sense obrir cap port.
|
|
285
|
+
--port <n> Port de nio al servidor (7717)
|
|
286
|
+
--local-port <n> Port en aquest ordinador (7718)
|
|
287
|
+
--dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
|
|
288
|
+
|
|
289
|
+
Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
|
|
211
290
|
Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
|
|
212
291
|
|
|
213
292
|
nio doctor Llista els agents instal·lats
|
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -108,9 +108,32 @@ function listProjects(cfg) {
|
|
|
108
108
|
return out;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
// Qualsevol carpeta existent d'aquest ordinador. Qui té la clau ja pot engegar agents, així que no la limitem a les compartides.
|
|
112
|
+
function isDir(p) {
|
|
113
|
+
try {
|
|
114
|
+
return fs.statSync(path.resolve(p)).isDirectory();
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Subcarpetes d'una carpeta, per a l'explorador de «Nova sessió».
|
|
121
|
+
function listDir(cfg, raw) {
|
|
122
|
+
const dir = path.resolve(raw || cfg.roots[0] || os.homedir());
|
|
123
|
+
if (!isDir(dir)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
124
|
+
let entries = [];
|
|
125
|
+
try {
|
|
126
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
throw httpErr(403, 'No tinc permís per veure aquesta carpeta');
|
|
129
|
+
}
|
|
130
|
+
const dirs = entries
|
|
131
|
+
.filter((e) => (e.isDirectory() || (e.isSymbolicLink() && isDir(path.join(dir, e.name)))) && e.name !== 'node_modules')
|
|
132
|
+
.map((e) => ({ name: e.name, path: path.join(dir, e.name), hidden: e.name.startsWith('.') }))
|
|
133
|
+
.sort((a, b) => a.hidden - b.hidden || a.name.localeCompare(b.name, 'ca'))
|
|
134
|
+
.slice(0, 500);
|
|
135
|
+
const parent = path.dirname(dir);
|
|
136
|
+
return { path: dir, parent: parent === dir ? null : parent, home: os.homedir(), git: fs.existsSync(path.join(dir, '.git')), dirs };
|
|
114
137
|
}
|
|
115
138
|
|
|
116
139
|
// Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
|
|
@@ -149,6 +172,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
149
172
|
};
|
|
150
173
|
}
|
|
151
174
|
if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
|
|
175
|
+
if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
|
|
152
176
|
if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
|
|
153
177
|
if (p === '/api/ollama' && method === 'GET') {
|
|
154
178
|
const models = await ollama.listModels();
|
|
@@ -222,8 +246,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
222
246
|
if (p === '/api/sessions' && method === 'POST') {
|
|
223
247
|
const b = await readBody(req);
|
|
224
248
|
const cwd = path.resolve(b.cwd || cfg.roots[0]);
|
|
225
|
-
if (!
|
|
226
|
-
if (!fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
249
|
+
if (!isDir(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
227
250
|
const s = hub.create({ agent: b.agent, cwd, title: b.title, mode: b.mode, model: b.model });
|
|
228
251
|
if (b.text) hub.send(s.id, b.text, hookUrl());
|
|
229
252
|
return hub.summary(s);
|
|
@@ -245,7 +268,6 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
245
268
|
if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
|
|
246
269
|
const s = hub.get(r[1]);
|
|
247
270
|
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
|
248
|
-
if (!insideRoots(cfg, s.cwd)) throw httpErr(403, 'Aquesta carpeta no és de les compartides');
|
|
249
271
|
if (!r[2] && method === 'GET') return github.gitInfo(s.cwd);
|
|
250
272
|
if (r[2] === 'commit' && method === 'POST') return github.commit(s.cwd, await readBody(req));
|
|
251
273
|
if (r[2] === 'pr' && method === 'POST') return github.pr(s.cwd, await readBody(req));
|
package/web/app/app.js
CHANGED
|
@@ -36,7 +36,6 @@ const MODES = {
|
|
|
36
36
|
preguntar: { label: 'Demana permís', short: 'Permís' },
|
|
37
37
|
tot: { label: 'Via lliure', short: 'Lliure' }
|
|
38
38
|
};
|
|
39
|
-
const DEMO_URL = 'demo:navegador';
|
|
40
39
|
|
|
41
40
|
/* ---------------------------------------------------------------- estat */
|
|
42
41
|
const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set() };
|
|
@@ -45,13 +44,12 @@ const LS = {
|
|
|
45
44
|
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
|
|
46
45
|
};
|
|
47
46
|
const store = {
|
|
48
|
-
get machines() { return LS.get('nio.machines', []); },
|
|
47
|
+
get machines() { return LS.get('nio.machines', []).filter((m) => m.url !== 'demo:navegador'); },
|
|
49
48
|
set machines(v) { LS.set('nio.machines', v); },
|
|
50
49
|
get active() { return Number(LS.get('nio.active', 0)) || 0; },
|
|
51
50
|
set active(i) { LS.set('nio.active', i); }
|
|
52
51
|
};
|
|
53
52
|
const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
54
|
-
const isDemo = () => machine()?.url === DEMO_URL;
|
|
55
53
|
|
|
56
54
|
/* ---------------------------------------------------------------- utilitats */
|
|
57
55
|
function toast(msg, ms = 2800) {
|
|
@@ -102,174 +100,23 @@ function prettyInput(input) {
|
|
|
102
100
|
return JSON.stringify(input, null, 2);
|
|
103
101
|
}
|
|
104
102
|
|
|
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
103
|
/* ---------------------------------------------------------------- API */
|
|
253
104
|
async function api(path, opts = {}) {
|
|
254
105
|
const m = machine();
|
|
255
106
|
if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
256
107
|
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
108
|
const res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined });
|
|
259
109
|
const data = await res.json().catch(() => ({}));
|
|
260
110
|
if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
|
|
111
|
+
if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
|
|
261
112
|
return data;
|
|
262
113
|
}
|
|
263
114
|
|
|
264
|
-
let streamCtrl = null,
|
|
115
|
+
let streamCtrl = null, retry = 0;
|
|
265
116
|
async function connect() {
|
|
266
|
-
streamCtrl?.abort();
|
|
117
|
+
streamCtrl?.abort();
|
|
267
118
|
const m = machine();
|
|
268
119
|
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
120
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
274
121
|
try {
|
|
275
122
|
state.info = await api('/api/info');
|
|
@@ -346,12 +193,11 @@ function parsePair(text) {
|
|
|
346
193
|
}
|
|
347
194
|
function addMachine({ url, token, name }) {
|
|
348
195
|
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 ||
|
|
196
|
+
if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
|
|
350
197
|
store.machines = ms; store.active = i;
|
|
351
198
|
state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
|
|
352
199
|
connect();
|
|
353
200
|
}
|
|
354
|
-
function tryDemo() { addMachine({ url: DEMO_URL, token: 'demo', name: 'Ordinador de prova' }); toast('Connectat a l’ordinador de prova'); go('#/sessions'); }
|
|
355
201
|
function checkPairHash() {
|
|
356
202
|
const pair = parsePair(location.hash); if (!pair) return false;
|
|
357
203
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
@@ -368,6 +214,11 @@ const VIEWS = {
|
|
|
368
214
|
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
|
|
369
215
|
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
|
|
370
216
|
};
|
|
217
|
+
const NOMACHINE = {
|
|
218
|
+
approvals: { title: 'Aprovacions', lead: 'Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.' },
|
|
219
|
+
models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
|
|
220
|
+
maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
|
|
221
|
+
};
|
|
371
222
|
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
|
|
372
223
|
function parseRoute() {
|
|
373
224
|
const h = location.hash.replace(/^#\/?/, '');
|
|
@@ -417,7 +268,7 @@ function render() {
|
|
|
417
268
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
418
269
|
else {
|
|
419
270
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
420
|
-
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html :
|
|
271
|
+
view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'sessions' ? (machine() ? viewSessions() : viewConnect()) : !machine() ? viewNoMachine(NOMACHINE[r.name]) : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
|
|
421
272
|
if (r.name === 'mobil') bindLocal();
|
|
422
273
|
if (r.name === 'models' && machine()) loadOllama();
|
|
423
274
|
}
|
|
@@ -427,29 +278,46 @@ function render() {
|
|
|
427
278
|
}
|
|
428
279
|
|
|
429
280
|
/* ---------- Connecta */
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
281
|
+
// La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
|
|
282
|
+
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
283
|
+
function viewNoMachine(what) {
|
|
284
|
+
return `<div class="top"><div><h1>${esc(what.title)}</h1><p>${esc(what.lead)}</p></div></div>
|
|
285
|
+
<div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
|
|
286
|
+
}
|
|
287
|
+
function pasteForm(open) {
|
|
288
|
+
return `<details class="paste" ${open ? 'open' : ''}><summary>Ja tens l’enllaç de connexió?</summary>
|
|
289
|
+
<form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
290
|
+
<p class="muted small">Surt al terminal, sota el codi QR.</p></details>`;
|
|
291
|
+
}
|
|
292
|
+
function viewConnect() {
|
|
293
|
+
const CMD = 'npx nio-cat', cmd = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
294
|
+
if (isPhone()) {
|
|
295
|
+
return `<section class="connect">
|
|
296
|
+
<div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen al teu ordinador, amb els teus comptes i els teus fitxers. Des del mòbil els dones feina i aproves el que facin.</p></div>
|
|
297
|
+
<div class="panel conn-main">
|
|
298
|
+
<ol class="stepper">
|
|
299
|
+
<li><span class="n">1</span><div><b>Engega nio a l’ordinador</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
|
|
300
|
+
<li><span class="n">2</span><div><b>Escaneja el QR</b><p>Amb la càmera del mòbil. S’obrirà nio connectat.</p></div></li>
|
|
301
|
+
</ol>
|
|
302
|
+
${cmd(CMD)}
|
|
303
|
+
<p class="muted small">Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span>. Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>
|
|
304
|
+
${pasteForm(false)}
|
|
305
|
+
</div>
|
|
306
|
+
</section>`;
|
|
307
|
+
}
|
|
447
308
|
return `<section class="connect">
|
|
448
|
-
<div class="connect-head"><h1>
|
|
449
|
-
<div class="
|
|
450
|
-
|
|
451
|
-
|
|
309
|
+
<div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen a l’ordinador, amb els teus comptes i els teus fitxers. Des d’aquí els dones feina, els veus treballar i aproves el que facin.</p></div>
|
|
310
|
+
<div class="choice-grid conn-main">
|
|
311
|
+
<section class="panel choice"><header><h2>Aquest ordinador</h2></header><div class="body stack">
|
|
312
|
+
<p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
|
|
313
|
+
<p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
|
|
314
|
+
<section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
|
|
315
|
+
<p class="small">Engega nio a l’altre ordinador o a un servidor. Copia l’enllaç que mostra i enganxa’l aquí.</p>
|
|
316
|
+
${pasteForm(true)}
|
|
317
|
+
<p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
|
|
318
|
+
<p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
|
|
452
319
|
</div>
|
|
320
|
+
<p class="muted small" style="margin-top:14px">Després, al mòbil, tria l’ordinador i escaneja el codi QR que mostra el panell.</p>
|
|
453
321
|
</section>`;
|
|
454
322
|
}
|
|
455
323
|
|
|
@@ -648,8 +516,8 @@ function renderComposer() {
|
|
|
648
516
|
}
|
|
649
517
|
|
|
650
518
|
/* ---------- Fulls */
|
|
651
|
-
function openSheet(html, onMount) {
|
|
652
|
-
const d = $('#sheet'); $('#sheetIn').innerHTML = `<div class="grab"></div>${html}`;
|
|
519
|
+
function openSheet(html, onMount, cls) {
|
|
520
|
+
const d = $('#sheet'); d.classList.toggle('wiz', cls === 'wiz'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls === 'wiz' ? '' : `<div class="grab"></div>${html}`;
|
|
653
521
|
if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
|
|
654
522
|
onMount?.($('#sheetIn'));
|
|
655
523
|
}
|
|
@@ -664,85 +532,122 @@ function modeHelp(agentId, mode) {
|
|
|
664
532
|
if (agentId === 'codex') return 'Pot editar dins la carpeta, sense accés a internet. Codex no pot fer preguntes en aquest mode.';
|
|
665
533
|
return 'Aquest agent encara no pot demanar permís: les accions que el necessitarien es rebutgen.';
|
|
666
534
|
}
|
|
535
|
+
// Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
|
|
667
536
|
async function newSessionSheet(preset = {}) {
|
|
668
537
|
if (!machine()) return go('#/sessions');
|
|
669
538
|
if (!state.connected) return toast('Encara no hi ha connexió amb l’ordinador');
|
|
670
539
|
const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
|
|
671
540
|
let projects = [];
|
|
672
541
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
673
|
-
const first =
|
|
674
|
-
const sel = { agent: first?.id || '
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
$('#
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
$('#
|
|
687
|
-
$$('#modePick button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.mode === sel.mode));
|
|
688
|
-
$('#modeHelp', sh).textContent = modeHelp(sel.agent, sel.mode);
|
|
542
|
+
const first = agents.find((a) => a.available);
|
|
543
|
+
const sel = { agent: first?.id || '', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: 'preguntar', model: '', text: '', ...preset };
|
|
544
|
+
const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
|
|
545
|
+
let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
|
|
546
|
+
const agentOf = () => agents.find((a) => a.id === sel.agent);
|
|
547
|
+
|
|
548
|
+
openSheet('', (sh) => {
|
|
549
|
+
const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
|
|
550
|
+
const head = () => {
|
|
551
|
+
const last = step === STEPS.length - 1;
|
|
552
|
+
$('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div>
|
|
553
|
+
<div class="wiz-dots">${STEPS.map((n, i) => `<button type="button" class="wiz-dot ${i === step ? 'on' : i < step ? 'done' : ''}" data-step="${i}" ${i < step ? '' : 'disabled'} aria-label="${i + 1}. ${n}"></button>`).join('')}</div>
|
|
554
|
+
<p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
|
|
555
|
+
$('#wizFoot', sh).innerHTML = `<button class="btn ghost" type="button" ${step ? 'data-back' : 'data-close'}>${step ? 'Enrere' : 'Cancel·la'}</button><button class="btn ink" type="button" ${last ? 'data-start' : 'data-next'}>${last ? 'Comença' : 'Continua'}</button>`;
|
|
689
556
|
};
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
}
|
|
557
|
+
const draw = () => {
|
|
558
|
+
if (!$('#wizBody', sh)) sh.innerHTML = '<div class="wiz-head" id="wizHead"></div><div class="wiz-body" id="wizBody"></div><div class="wiz-foot" id="wizFoot"></div>';
|
|
559
|
+
head(); body();
|
|
560
|
+
$('#wizBody', sh).scrollTop = 0;
|
|
702
561
|
};
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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>';
|
|
562
|
+
const after = () => {
|
|
563
|
+
if (step === 1 && !fsd) fsGo(sel.cwd);
|
|
564
|
+
if (step === 2 && sel.agent === 'ollama' && models == null) api('/api/ollama/models').catch(() => []).then((m) => { models = m; if (!sel.model) sel.model = m[0]?.name || ''; if (step === 2) body(); });
|
|
565
|
+
if (step === 3 && !isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 60);
|
|
708
566
|
};
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
567
|
+
|
|
568
|
+
/* --- carpeta: la carpeta oberta és la triada */
|
|
569
|
+
const fsGo = async (dir) => {
|
|
570
|
+
try { fsd = await api(`/api/fs?path=${encodeURIComponent(dir || '')}`); sel.cwd = fsd.path; gh = null; }
|
|
571
|
+
catch (e) { toast(e.message, 4200); if (!fsd) fsd = { path: sel.cwd, parent: null, home: '', dirs: [] }; }
|
|
572
|
+
if (step === 1) body();
|
|
573
|
+
};
|
|
574
|
+
const ghList = () => {
|
|
575
|
+
const q = (gh.q || '').trim().toLowerCase(), list = (gh.repos || []).filter((r) => !q || r.repo.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 40);
|
|
576
|
+
return list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
|
|
577
|
+
};
|
|
578
|
+
const ghStart = async () => {
|
|
579
|
+
gh = { msg: 'Connectant amb GitHub…', repos: null, q: '' }; body();
|
|
580
|
+
let st; try { st = await api('/api/github'); } catch (e) { gh.msg = esc(e.message); return body(); }
|
|
581
|
+
if (!st.available) gh.msg = 'No trobo l’eina <span class="mono">gh</span> en aquest ordinador. Instal·la-la des de <a href="https://cli.github.com" target="_blank" rel="noopener">cli.github.com</a> i inicia sessió amb <span class="mono">gh auth login</span>.';
|
|
582
|
+
else if (!st.authed) gh.msg = 'Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.';
|
|
583
|
+
else { gh.user = st.user; gh.msg = 'Carregant repositoris…'; body(); try { gh.repos = await api('/api/github/repos'); gh.msg = ''; } catch (e) { gh.msg = esc(e.message); } }
|
|
584
|
+
if (gh && step === 1) body();
|
|
718
585
|
};
|
|
719
|
-
$('#ghOpen', sh).onclick = ghOpen;
|
|
720
586
|
const ghClone = async (repo, btn) => {
|
|
721
587
|
btn.disabled = true; btn.querySelector('.p, div')?.insertAdjacentHTML('beforeend', ' <span class="muted">· clonant…</span>');
|
|
722
|
-
try {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
588
|
+
try { const r = await api('/api/github/clone', { method: 'POST', body: { repo } }); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat'); await fsGo(r.path); }
|
|
589
|
+
catch (e) { btn.disabled = false; toast(e.message, 6000); }
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const VIEWS_ = [
|
|
593
|
+
() => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
|
|
594
|
+
() => {
|
|
595
|
+
const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
|
|
596
|
+
const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
|
|
597
|
+
return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
|
|
598
|
+
<form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
|
|
599
|
+
<div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
|
|
600
|
+
<div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
|
|
601
|
+
${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
|
|
602
|
+
: !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
|
|
603
|
+
<div class="proj-list fs-list">${fsd.dirs.map((d) => `<button type="button" class="proj" data-fs="${esc(d.path)}"><span class="ic">›</span><span class="grow"><div ${d.hidden ? 'class="muted"' : ''}>${esc(d.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`}`;
|
|
604
|
+
},
|
|
605
|
+
() => {
|
|
606
|
+
const ag = agentOf();
|
|
607
|
+
const modelHtml = sel.agent === 'ollama'
|
|
608
|
+
? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
|
|
609
|
+
: `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
|
|
610
|
+
return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
|
|
611
|
+
},
|
|
612
|
+
() => `<p class="wiz-q">Què ha de fer?</p>
|
|
613
|
+
<dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
|
|
614
|
+
<label class="f"><span>Explica-li la tasca</span><textarea class="field" id="firstMsg" rows="5" placeholder="Per exemple: troba per què fallen els tests i arregla-ho">${esc(sel.text)}</textarea></label>`
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
const start = async () => {
|
|
618
|
+
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
619
|
+
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
620
|
+
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
|
|
621
|
+
catch (e) { toast(e.message, 4200); btn.disabled = false; }
|
|
727
622
|
};
|
|
728
623
|
sh.onclick = (e) => {
|
|
729
|
-
const
|
|
730
|
-
if (
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
|
|
624
|
+
const t = e.target;
|
|
625
|
+
if (t.closest('[data-close]')) return closeSheet();
|
|
626
|
+
if (t.closest('[data-back]')) { step = Math.max(0, step - 1); return draw(); }
|
|
627
|
+
if (t.closest('[data-next]')) {
|
|
628
|
+
if (step === 0 && !sel.agent) return toast('Tria un agent');
|
|
629
|
+
if (step === 1 && !sel.cwd) return toast('Tria una carpeta');
|
|
630
|
+
step++; return draw();
|
|
631
|
+
}
|
|
632
|
+
if (t.closest('[data-start]')) return start();
|
|
633
|
+
const st = t.closest('[data-step]'); if (st && !st.disabled) { step = +st.dataset.step; return draw(); }
|
|
634
|
+
const ag = t.closest('[data-agent-id]');
|
|
635
|
+
if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); if (a.id !== sel.agent) { sel.agent = a.id; sel.model = ''; models = null; } step = 1; return draw(); }
|
|
636
|
+
const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
|
|
637
|
+
if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
|
|
638
|
+
const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
|
|
639
|
+
const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
|
|
735
640
|
};
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
catch (e) { toast(e.message, 4200); $('#startBtn', sh).disabled = false; }
|
|
641
|
+
sh.onsubmit = (e) => { e.preventDefault(); if (e.target.id === 'fsForm') fsGo($('#fsIn', sh).value.trim()); };
|
|
642
|
+
sh.oninput = (e) => {
|
|
643
|
+
const id = e.target.id;
|
|
644
|
+
if (id === 'firstMsg') sel.text = e.target.value;
|
|
645
|
+
else if (id === 'modelIn') sel.model = e.target.value.trim();
|
|
646
|
+
else if (id === 'ghQ' && gh) { gh.q = e.target.value; $('#ghList', sh).innerHTML = ghList(); }
|
|
743
647
|
};
|
|
744
|
-
|
|
745
|
-
|
|
648
|
+
sh.onchange = (e) => { if (e.target.id === 'modelSel') sel.model = e.target.value; };
|
|
649
|
+
draw();
|
|
650
|
+
}, 'wiz');
|
|
746
651
|
}
|
|
747
652
|
function modeSheet(s) {
|
|
748
653
|
openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
|
|
@@ -753,7 +658,6 @@ function modeSheet(s) {
|
|
|
753
658
|
});
|
|
754
659
|
}
|
|
755
660
|
/* ---------- Ordinadors i mòbil */
|
|
756
|
-
const isRealMachine = () => { const m = machine(); return !!m && m.url !== DEMO_URL; };
|
|
757
661
|
const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
|
|
758
662
|
function mobileHint() {
|
|
759
663
|
if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
|
|
@@ -761,7 +665,7 @@ function mobileHint() {
|
|
|
761
665
|
}
|
|
762
666
|
function machinesSheet() {
|
|
763
667
|
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
|
|
668
|
+
openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
|
|
765
669
|
<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-act="add-machine">${svg(ICON.plus)}Connecta’n un altre</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
766
670
|
}
|
|
767
671
|
function addMachineSheet() {
|
|
@@ -769,7 +673,8 @@ function addMachineSheet() {
|
|
|
769
673
|
openSheet(`<h2>Connecta un altre ordinador</h2>
|
|
770
674
|
<div class="stack-lg">
|
|
771
675
|
<div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
|
|
772
|
-
<div class="stack"><b>Un servidor (VPS)</b><p class="muted small">
|
|
676
|
+
<div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
|
|
677
|
+
<div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia l’enllaç que mostra i enganxa’l aquí.</p></div>
|
|
773
678
|
<form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://…/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
774
679
|
</div>`);
|
|
775
680
|
}
|
|
@@ -826,7 +731,7 @@ function sessionOptions(id) {
|
|
|
826
731
|
openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
|
|
827
732
|
<dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[s.agent] || s.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(s.cwd)}</dd><dt>Permisos</dt><dd>${esc(MODES[s.mode]?.label || '')}</dd><dt>Creada</dt><dd>${new Date(s.created).toLocaleString('ca')}</dd></dl>
|
|
828
733
|
<label class="f"><span>Nom de la sessió</span><input type="text" id="renameIn" value="${esc(s.title)}" maxlength="120"></label>
|
|
829
|
-
|
|
734
|
+
<button class="btn" data-act="git" type="button">Git i GitHub…</button>
|
|
830
735
|
<div class="row between"><button class="btn danger" id="delBtn" type="button">Esborra la sessió</button><button class="btn ink" id="renameBtn" type="button">Desa</button></div>`, (sh) => {
|
|
831
736
|
$('#renameBtn', sh).onclick = async () => { const t = $('#renameIn', sh).value.trim(); const sum = await api(`/api/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toast(e.message)); if (sum) state.sessions.set(id, sum); closeSheet(); renderSessionHead(); };
|
|
832
737
|
$('#delBtn', sh).onclick = async () => { if (!confirm('S’esborrarà la conversa d’aquesta sessió. Els fitxers del projecte no es toquen.')) return; await api(`/api/sessions/${id}`, { method: 'DELETE' }).catch((e) => toast(e.message)); closeSheet(); go('#/sessions'); };
|
|
@@ -836,8 +741,7 @@ function sessionOptions(id) {
|
|
|
836
741
|
/* ---------- Ordinador */
|
|
837
742
|
function viewMachine() {
|
|
838
743
|
const m = machine(), info = state.info, ms = store.machines;
|
|
839
|
-
|
|
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>
|
|
744
|
+
return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><div class="row"><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div></div>
|
|
841
745
|
<div class="grid2">
|
|
842
746
|
<div class="stack">
|
|
843
747
|
<section class="panel"><header><h2>Agents</h2></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Sense connexió.</p>'}</div></section>
|
|
@@ -845,9 +749,9 @@ function viewMachine() {
|
|
|
845
749
|
</div>
|
|
846
750
|
<div class="stack">
|
|
847
751
|
<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
|
|
848
|
-
${
|
|
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
|
|
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
|
|
752
|
+
${'<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>'}
|
|
753
|
+
<section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
|
|
754
|
+
<section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
|
|
851
755
|
<div class="row"><button class="btn" data-act="add-machine" type="button">${svg(ICON.plus)}Connecta’n un altre</button><button class="btn danger" data-act="forget" type="button">Oblida aquest</button></div></div></section>
|
|
852
756
|
</div>
|
|
853
757
|
</div>`;
|
|
@@ -939,7 +843,7 @@ function onOllama(msg) {
|
|
|
939
843
|
window.NioBrains = {
|
|
940
844
|
list() {
|
|
941
845
|
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}
|
|
846
|
+
if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
|
|
943
847
|
if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
|
|
944
848
|
return out;
|
|
945
849
|
},
|
|
@@ -966,7 +870,6 @@ async function brainsForBoard() {
|
|
|
966
870
|
function b64uToU8(s) { const b = atob((s + '='.repeat((4 - (s.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from(b, (c) => c.charCodeAt(0)); }
|
|
967
871
|
async function enablePush() {
|
|
968
872
|
try {
|
|
969
|
-
if (isDemo()) throw new Error('Els avisos només funcionen amb un ordinador de veritat');
|
|
970
873
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error(isIOS ? 'Afegeix nio a la pantalla d’inici i obre’l des d’allà' : 'Aquest navegador no admet avisos');
|
|
971
874
|
if ((await Notification.requestPermission()) !== 'granted') throw new Error('Cal que permetis les notificacions');
|
|
972
875
|
const reg = await navigator.serviceWorker.ready;
|
|
@@ -976,7 +879,7 @@ async function enablePush() {
|
|
|
976
879
|
} catch (e) { toast(e.message, 4200); }
|
|
977
880
|
}
|
|
978
881
|
async function syncServiceWorker() {
|
|
979
|
-
const m = machine(); if (!m ||
|
|
882
|
+
const m = machine(); if (!m || !('serviceWorker' in navigator)) return;
|
|
980
883
|
try { (await navigator.serviceWorker.ready).active?.postMessage({ type: 'nio-cfg', url: m.url, token: m.token }); } catch { /* sense service worker */ }
|
|
981
884
|
}
|
|
982
885
|
|
|
@@ -1085,7 +988,6 @@ document.addEventListener('click', async (e) => {
|
|
|
1085
988
|
case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
|
|
1086
989
|
case 'ollama-del': return deleteModel(a.dataset.name);
|
|
1087
990
|
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
991
|
case 'retry': return connect();
|
|
1090
992
|
case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
|
|
1091
993
|
case 'session-more': return sessionOptions(state.route.id);
|
|
@@ -1100,7 +1002,7 @@ document.addEventListener('click', async (e) => {
|
|
|
1100
1002
|
case 'forget': {
|
|
1101
1003
|
if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
|
|
1102
1004
|
const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
|
|
1103
|
-
streamCtrl?.abort();
|
|
1005
|
+
streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
|
|
1104
1006
|
connect(); return render();
|
|
1105
1007
|
}
|
|
1106
1008
|
case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
|
|
@@ -1126,7 +1028,6 @@ function commands() {
|
|
|
1126
1028
|
const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
|
|
1127
1029
|
if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
|
|
1128
1030
|
[...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
|
|
1129
|
-
if (!isDemo()) c.push({ label: 'Prova l’ordinador simulat', hint: 'Prova', run: tryDemo });
|
|
1130
1031
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1131
1032
|
return c;
|
|
1132
1033
|
}
|
|
@@ -1202,8 +1103,7 @@ const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
|
|
|
1202
1103
|
const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
1203
1104
|
const TSTEPS = [
|
|
1204
1105
|
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA del teu ordinador. En un minut t’ensenyo on és cada cosa.', next: 'Comencem' },
|
|
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.
|
|
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.' },
|
|
1106
|
+
{ route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix.') },
|
|
1207
1107
|
{ route: 'approvals', target: '.side nav a[href="#/aprovacions"]', m: '#mRight .icon-btn', title: 'Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
|
|
1208
1108
|
{ route: 'mobil', target: '.side nav a[href="#/mobil"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
|
|
1209
1109
|
{ route: 'models', target: '.side nav a[href="#/models"]', m: '.page .top h1, .connect-head h1', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models, veus quins tens i obres sessions amb el que vulguis. Res no surt de casa.' },
|
|
@@ -1305,7 +1205,7 @@ if ('serviceWorker' in navigator) {
|
|
|
1305
1205
|
navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
|
|
1306
1206
|
}
|
|
1307
1207
|
addEventListener('hashchange', route);
|
|
1308
|
-
document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected
|
|
1208
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
|
|
1309
1209
|
setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
|
|
1310
1210
|
const qs = new URLSearchParams(location.search).get('s');
|
|
1311
1211
|
if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
|
package/web/app/style.css
CHANGED
|
@@ -148,7 +148,7 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
148
148
|
.connect { display: grid; gap: 24px; min-width: 0; }
|
|
149
149
|
.connect-head h1 { font-size: clamp(30px, 4vw, 40px); font-weight: 500; letter-spacing: -.05em; line-height: 1.08; }
|
|
150
150
|
.connect-head .lead { color: var(--fg-muted); font-size: 16px; max-width: 58ch; margin-top: 10px; }
|
|
151
|
-
.connect-grid { display: grid; grid-template-columns: minmax(0,
|
|
151
|
+
.connect-grid { display: grid; grid-template-columns: minmax(0, 720px); gap: 16px; align-items: start; }
|
|
152
152
|
.conn-main { padding: 22px; display: grid; gap: 16px; min-width: 0; }
|
|
153
153
|
.stepper { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
154
154
|
.stepper li { display: flex; gap: 10px; align-items: flex-start; }
|
|
@@ -379,3 +379,41 @@ body.board-mode .view-enter { animation: none; }
|
|
|
379
379
|
.qr-box { width: min(260px, 100%); margin: 0 auto; padding: 14px; border-radius: 16px; background: #fffefd; border: 1px solid var(--border); }
|
|
380
380
|
.qr-box svg { display: block; width: 100%; height: auto; }
|
|
381
381
|
#ghWrap input[type="search"] { margin-bottom: 4px; }
|
|
382
|
+
|
|
383
|
+
/* Connexió: dues opcions a l'ordinador; explorador de carpetes a Nova sessió */
|
|
384
|
+
.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
|
|
385
|
+
.choice .body { min-width: 0; }
|
|
386
|
+
@media (max-width: 820px) { .choice-grid { grid-template-columns: 1fr; } }
|
|
387
|
+
.fs-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
388
|
+
.fs-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
|
|
389
|
+
.fs-list { max-height: 240px; overflow: auto; }
|
|
390
|
+
|
|
391
|
+
/* Assistent «Nova sessió»: drawer fix al telèfon, capçalera i peu fixos, cos amb scroll */
|
|
392
|
+
body:has(dialog.sheet[open]) { overflow: hidden; }
|
|
393
|
+
dialog.sheet { overscroll-behavior: contain; }
|
|
394
|
+
dialog.sheet.wiz { height: min(680px, 88dvh); overflow: hidden; }
|
|
395
|
+
dialog.sheet.wiz[open] { display: flex; flex-direction: column; }
|
|
396
|
+
dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
397
|
+
.wiz-head { flex: none; display: grid; gap: 8px; padding: 20px 24px 12px; }
|
|
398
|
+
.wiz-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 6px 24px 18px; display: grid; gap: 14px; align-content: start; }
|
|
399
|
+
.wiz-foot { flex: none; display: flex; justify-content: space-between; gap: 10px; padding: 12px 24px calc(16px + var(--safe-b)); border-top: 1px solid var(--border); background: var(--bg); }
|
|
400
|
+
.wiz-foot .btn { min-width: 110px; justify-content: center; }
|
|
401
|
+
.wiz-q { font-size: 18px; font-weight: 500; letter-spacing: -.03em; }
|
|
402
|
+
.wiz-dots { display: flex; gap: 6px; }
|
|
403
|
+
.wiz-dot { flex: 1; height: 18px; border: 0; background: var(--border) content-box; background-clip: content-box; padding: 7px 0; border-radius: 4px; }
|
|
404
|
+
.wiz-dot.on { background-color: var(--fg); }
|
|
405
|
+
.wiz-dot.done { background-color: var(--moss); }
|
|
406
|
+
.wiz-dot:disabled { cursor: default; }
|
|
407
|
+
.wiz .card[aria-pressed="true"] { border-color: var(--fg); box-shadow: inset 0 0 0 1px var(--fg); }
|
|
408
|
+
.fs-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px; border-radius: 10px; background: var(--bg-2); }
|
|
409
|
+
.fs-pick b { overflow-wrap: anywhere; }
|
|
410
|
+
.fs-list { max-height: none; }
|
|
411
|
+
@media (max-width: 820px) {
|
|
412
|
+
dialog.sheet.wiz { height: min(92dvh, calc(var(--vvh, 100dvh) - 8px)); }
|
|
413
|
+
.wiz-head { padding: 10px 18px 8px; }
|
|
414
|
+
.wiz-body { padding: 4px 18px 14px; }
|
|
415
|
+
.wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
|
|
416
|
+
.wiz-foot .btn { flex: 1; }
|
|
417
|
+
}
|
|
418
|
+
#fsForm { display: flex; gap: 8px; }
|
|
419
|
+
#fsForm input { flex: 1; min-width: 0; }
|
package/web/index.html
CHANGED
|
@@ -116,11 +116,6 @@ main strong { font-weight: 600; }
|
|
|
116
116
|
<h2 id="limits">Tu decideixes</h2>
|
|
117
117
|
<p>Cada sessió té el seu nivell de confiança: <strong>només llegir</strong>, <strong>demana permís</strong> abans d’editar o executar, o <strong>via lliure</strong>. Quan l’agent et necessita, t’arriba un avís i ho resols amb un toc.</p>
|
|
118
118
|
</section>
|
|
119
|
-
|
|
120
|
-
<section aria-labelledby="prova">
|
|
121
|
-
<h2 id="prova">Prova-ho</h2>
|
|
122
|
-
<p>Sense instal·lar res, amb un ordinador simulat: <a href="/app">obre el panell</a>. També hi trobaràs els teus models d’Ollama i un model petit que funciona dins del mateix mòbil.</p>
|
|
123
|
-
</section>
|
|
124
119
|
</main>
|
|
125
120
|
|
|
126
121
|
<footer class="footer">
|