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