nio-cat 0.9.13 → 0.9.15
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/package.json +1 -1
- package/src/agents/claude.js +6 -2
- package/src/control.js +5 -3
- package/src/hub.js +2 -1
- package/src/mcp.js +4 -3
- package/src/notes.js +4 -3
- package/src/proc.js +61 -12
- package/src/service.js +51 -4
- package/src/telegram.js +18 -1
- package/web/app/android.js +37 -0
- package/web/app/app.js +79 -15
- package/web/app/index.html +1 -0
- package/web/app/notes.css +6 -0
- package/web/app/notes.js +36 -11
- package/web/app/style.css +4 -0
- package/web/index.html +17 -1
- package/web/sw.js +2 -2
package/package.json
CHANGED
package/src/agents/claude.js
CHANGED
|
@@ -5,6 +5,10 @@ const path = require('path');
|
|
|
5
5
|
const { run, tryJson } = require('../proc');
|
|
6
6
|
|
|
7
7
|
const HOOK = path.join(__dirname, '..', 'claude-hook.js');
|
|
8
|
+
// L'ordre del hook la llança la shell de Claude Code (a Windows, Git Bash): el Node d'aquest mateix procés i
|
|
9
|
+
// barres «/», que entenen totes les shells.
|
|
10
|
+
const slash = (p) => (process.platform === 'win32' ? p.replace(/\\/g, '/') : p);
|
|
11
|
+
const HOOK_CMD = `"${slash(process.execPath)}" "${slash(HOOK)}"`;
|
|
8
12
|
|
|
9
13
|
module.exports = {
|
|
10
14
|
id: 'claude',
|
|
@@ -30,7 +34,7 @@ module.exports = {
|
|
|
30
34
|
const env = {};
|
|
31
35
|
const gate = (extra = {}) => {
|
|
32
36
|
// Hook PreToolUse: envia al mòbil el que cal aprovar.
|
|
33
|
-
args.push('--settings', JSON.stringify({ hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command:
|
|
37
|
+
args.push('--settings', JSON.stringify({ hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: HOOK_CMD, timeout: 900 }] }] } }));
|
|
34
38
|
Object.assign(env, { NIO_HOOK_URL: ctx.hook.url, NIO_HOOK_KEY: ctx.hook.key, NIO_SESSION: session.id }, extra);
|
|
35
39
|
};
|
|
36
40
|
const control = cfg.control?.navegador || cfg.control?.escriptori;
|
|
@@ -47,7 +51,7 @@ module.exports = {
|
|
|
47
51
|
PreToolUse: [
|
|
48
52
|
{
|
|
49
53
|
matcher: '*',
|
|
50
|
-
hooks: [{ type: 'command', command:
|
|
54
|
+
hooks: [{ type: 'command', command: HOOK_CMD, timeout: 900 }],
|
|
51
55
|
},
|
|
52
56
|
],
|
|
53
57
|
},
|
package/src/control.js
CHANGED
|
@@ -10,7 +10,7 @@ const os = require('os');
|
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const crypto = require('crypto');
|
|
12
12
|
const { spawn } = require('child_process');
|
|
13
|
-
const { which, childEnv } = require('./proc');
|
|
13
|
+
const { which, childEnv, command, killTree } = require('./proc');
|
|
14
14
|
const config = require('./config');
|
|
15
15
|
const { HOME } = config;
|
|
16
16
|
|
|
@@ -61,11 +61,13 @@ function run(bin, args, timeout = 15000) {
|
|
|
61
61
|
resolve({ ok, out, err: err || why || '', timedOut: why === 'temps' });
|
|
62
62
|
};
|
|
63
63
|
const timer = setTimeout(() => {
|
|
64
|
-
|
|
64
|
+
if (process.platform === 'win32') killTree(child?.pid);
|
|
65
|
+
else try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* ja no hi és */ } }
|
|
65
66
|
finish(false, 'temps');
|
|
66
67
|
}, timeout);
|
|
67
68
|
try {
|
|
68
|
-
|
|
69
|
+
const c = command(bin, args);
|
|
70
|
+
child = spawn(c.cmd, c.args, { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', windowsHide: true, ...c.opts });
|
|
69
71
|
} catch (e) { return finish(false, e.message); }
|
|
70
72
|
child.stdout.on('data', (d) => { if (out.length < 8 << 20) out += d; });
|
|
71
73
|
child.stderr.on('data', (d) => { if (err.length < 1 << 20) err += d; });
|
package/src/hub.js
CHANGED
|
@@ -194,7 +194,8 @@ class Hub extends EventEmitter {
|
|
|
194
194
|
codexStatus(s, bp) {
|
|
195
195
|
const { execFile } = require('child_process');
|
|
196
196
|
const codex = s.agent === 'codex', name = byId[s.agent]?.name || s.agent;
|
|
197
|
-
|
|
197
|
+
const c = require('./proc').command(bp, codex ? ['login', 'status'] : ['--version']);
|
|
198
|
+
execFile(c.cmd, c.args, { timeout: 8000, windowsHide: true, ...c.opts }, (err, out, errOut) => {
|
|
198
199
|
const rows = String(out || errOut || '').split('\n').map((l) => l.trim()).filter(Boolean), login = rows.find((l) => /logged|log in|login/i.test(l) && !/^warning/i.test(l)) || rows[0] || (err ? 'No he pogut llegir el compte' : '');
|
|
199
200
|
const lines = [codex ? `**Compte:** ${login}` : `**Versió:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? `continua la de ${name}` : 'encara no ha començat'}`];
|
|
200
201
|
this.push(s, 'text', { text: lines.join('\n\n') });
|
package/src/mcp.js
CHANGED
|
@@ -122,10 +122,11 @@ function command() {
|
|
|
122
122
|
function registerClaude(bin, { force } = {}) {
|
|
123
123
|
if (!bin) return 'Claude Code no hi és';
|
|
124
124
|
const { cmd, args } = command();
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
const sync = (args, o) => { const c = require('./proc').command(bin, args); return spawnSync(c.cmd, c.args, { windowsHide: true, ...o, ...c.opts }); };
|
|
126
|
+
if (!force && sync(['mcp', 'get', 'nio'], { encoding: 'utf8', timeout: 15000 }).status === 0) return 'ja hi era';
|
|
127
|
+
sync(['mcp', 'remove', '--scope', 'user', 'nio'], { timeout: 15000 });
|
|
127
128
|
const env = process.env.NIO_HOME ? ['-e', `NIO_HOME=${process.env.NIO_HOME}`] : [];
|
|
128
|
-
const r =
|
|
129
|
+
const r = sync(['mcp', 'add', 'nio', '--scope', 'user', ...env, '--', cmd, ...args], { encoding: 'utf8', timeout: 15000 });
|
|
129
130
|
return r.status === 0 ? 'afegit' : `no s’ha pogut afegir: ${(r.stderr || r.stdout || '').trim().split('\n').pop()}`;
|
|
130
131
|
}
|
|
131
132
|
|
package/src/notes.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const crypto = require('crypto');
|
|
9
9
|
const { spawn } = require('child_process');
|
|
10
10
|
const { HOME } = require('./config');
|
|
11
|
-
const { childEnv } = require('./proc');
|
|
11
|
+
const { childEnv, command, killTree } = require('./proc');
|
|
12
12
|
const voice = require('./voice');
|
|
13
13
|
const ollama = require('./agents/ollama');
|
|
14
14
|
|
|
@@ -470,9 +470,10 @@ function parseJson(raw) {
|
|
|
470
470
|
// ---------- Execució d'un model, d'un sol cop
|
|
471
471
|
function runCli(bin, args, input, { cwd, timeout = 180000 } = {}) {
|
|
472
472
|
return new Promise((resolve, reject) => {
|
|
473
|
-
const
|
|
473
|
+
const c = command(bin, args);
|
|
474
|
+
const p = spawn(c.cmd, c.args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, ...c.opts });
|
|
474
475
|
let out = '', err = '';
|
|
475
|
-
const t = setTimeout(() => { p.kill('SIGKILL'); reject(httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.')); }, timeout);
|
|
476
|
+
const t = setTimeout(() => { if (process.platform === 'win32') killTree(p.pid); else p.kill('SIGKILL'); reject(httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.')); }, timeout);
|
|
476
477
|
p.stdout.on('data', (d) => { out += d; });
|
|
477
478
|
p.stderr.on('data', (d) => { err += d; });
|
|
478
479
|
p.on('error', (e) => { clearTimeout(t); reject(httpErr(500, `No he pogut engegar ${path.basename(bin)}: ${e.message}`)); });
|
package/src/proc.js
CHANGED
|
@@ -5,27 +5,28 @@ const os = require('os');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { spawn, execFile } = require('child_process');
|
|
7
7
|
|
|
8
|
+
const WIN = process.platform === 'win32';
|
|
8
9
|
const EXTRA_DIRS = [
|
|
9
10
|
path.join(os.homedir(), '.nio', 'bin'),
|
|
11
|
+
// Windows: npm -g, l'instal·lador natiu de Claude Code i winget.
|
|
12
|
+
...(WIN ? [process.env.APPDATA && path.join(process.env.APPDATA, 'npm'), process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links'), process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'nodejs')].filter(Boolean) : []),
|
|
10
13
|
path.join(os.homedir(), '.local', 'bin'),
|
|
11
14
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
12
15
|
path.join(os.homedir(), '.bun', 'bin'),
|
|
13
16
|
path.join(os.homedir(), '.grok', 'bin'),
|
|
14
|
-
'/opt/homebrew/bin',
|
|
15
|
-
'/usr/local/bin',
|
|
16
|
-
'/usr/bin',
|
|
17
|
+
...(WIN ? [] : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']),
|
|
17
18
|
];
|
|
18
19
|
|
|
19
20
|
function searchPath() {
|
|
20
|
-
const parts = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
21
|
-
for (const d of EXTRA_DIRS) if (!parts.
|
|
21
|
+
const parts = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean);
|
|
22
|
+
for (const d of EXTRA_DIRS) if (!parts.some((x) => (WIN ? x.toLowerCase() === d.toLowerCase() : x === d))) parts.push(d);
|
|
22
23
|
return parts;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function which(bin) {
|
|
26
27
|
if (!bin) return null;
|
|
27
28
|
if (bin.includes('/') || bin.includes('\\')) return fs.existsSync(bin) ? bin : null;
|
|
28
|
-
const exts =
|
|
29
|
+
const exts = WIN ? ['.exe', '.cmd', '.bat'] : [''];
|
|
29
30
|
for (const dir of searchPath()) {
|
|
30
31
|
for (const ext of exts) {
|
|
31
32
|
const p = path.join(dir, bin + ext);
|
|
@@ -40,9 +41,44 @@ function which(bin) {
|
|
|
40
41
|
return null;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// Windows: els CLI de npm (claude.cmd, codex.cmd…) són scripts .cmd, i Node no els executa sense una shell
|
|
45
|
+
// (i passar-hi el text de l'usuari per cmd.exe seria perillós). Es llegeix l'script i s'executa directament
|
|
46
|
+
// el que hi ha a dins: «node …\cli.js» o un .exe. Si no s'entén, cmd.exe amb els arguments escapats.
|
|
47
|
+
const shimCache = new Map();
|
|
48
|
+
function command(bin, args = []) {
|
|
49
|
+
if (!WIN || !/\.(cmd|bat)$/i.test(bin)) return { cmd: bin, args, opts: {} };
|
|
50
|
+
let target = shimCache.get(bin);
|
|
51
|
+
if (target === undefined) {
|
|
52
|
+
target = null;
|
|
53
|
+
try {
|
|
54
|
+
const txt = fs.readFileSync(bin, 'utf8');
|
|
55
|
+
// El primer «"%dp0%\…"» que existeixi, sense comptar el node.exe opcional de la mateixa carpeta.
|
|
56
|
+
for (const m of txt.matchAll(/"%~?dp0%?\\?([^"%]+?\.(?:js|cjs|mjs|exe))"/gi)) {
|
|
57
|
+
const p = path.join(path.dirname(bin), m[1]);
|
|
58
|
+
if (!/^node\.exe$/i.test(m[1]) && fs.existsSync(p)) { target = p; break; }
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
/* no es pot llegir */
|
|
62
|
+
}
|
|
63
|
+
shimCache.set(bin, target);
|
|
64
|
+
}
|
|
65
|
+
if (target && /\.exe$/i.test(target)) return { cmd: target, args, opts: {} };
|
|
66
|
+
if (target) return { cmd: process.execPath, args: [target, ...args], opts: {} };
|
|
67
|
+
const line = [bin, ...args].map(cmdQuote).join(' ');
|
|
68
|
+
return { cmd: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], opts: { windowsVerbatimArguments: true } };
|
|
69
|
+
}
|
|
70
|
+
// Cometes per a cmd.exe: les de CreateProcess i, a sobre, ^ davant dels caràcters especials de cmd.
|
|
71
|
+
function cmdQuote(a) {
|
|
72
|
+
let s = String(a);
|
|
73
|
+
if (s && !/[\s"^&|<>%!()]/.test(s)) return s;
|
|
74
|
+
s = `"${s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
|
|
75
|
+
return s.replace(/[\^&|<>%!()"]/g, '^$&');
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
function version(binPath, args = ['--version']) {
|
|
44
79
|
return new Promise((resolve) => {
|
|
45
|
-
|
|
80
|
+
const c = command(binPath, args);
|
|
81
|
+
execFile(c.cmd, c.args, { timeout: 4000, env: childEnv(), windowsHide: true, ...c.opts }, (err, stdout, stderr) => {
|
|
46
82
|
const out = String(stdout || stderr || '').trim().split('\n')[0];
|
|
47
83
|
resolve(err && !out ? null : out.slice(0, 60));
|
|
48
84
|
});
|
|
@@ -75,11 +111,14 @@ function run({ bin, args, cwd, env, stdin, onLine, onErr, onExit, tty }) {
|
|
|
75
111
|
argv = ['-qfec', [bin, ...args].map(shQuote).join(' '), '/dev/null'];
|
|
76
112
|
}
|
|
77
113
|
}
|
|
78
|
-
const
|
|
114
|
+
const c = command(cmd, argv);
|
|
115
|
+
const child = spawn(c.cmd, c.args, {
|
|
79
116
|
cwd,
|
|
80
117
|
env: childEnv(env),
|
|
81
118
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
82
|
-
detached:
|
|
119
|
+
detached: !WIN,
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
...c.opts,
|
|
83
122
|
});
|
|
84
123
|
if (stdin != null) child.stdin.end(stdin);
|
|
85
124
|
else child.stdin.end();
|
|
@@ -109,8 +148,8 @@ function run({ bin, args, cwd, env, stdin, onLine, onErr, onExit, tty }) {
|
|
|
109
148
|
child,
|
|
110
149
|
kill() {
|
|
111
150
|
try {
|
|
112
|
-
if (
|
|
113
|
-
else child.
|
|
151
|
+
if (!WIN) process.kill(-child.pid, 'SIGTERM');
|
|
152
|
+
else killTree(child.pid);
|
|
114
153
|
} catch {
|
|
115
154
|
try {
|
|
116
155
|
child.kill();
|
|
@@ -122,6 +161,16 @@ function run({ bin, args, cwd, env, stdin, onLine, onErr, onExit, tty }) {
|
|
|
122
161
|
};
|
|
123
162
|
}
|
|
124
163
|
|
|
164
|
+
// Windows: atura el procés i tots els que ha obert (l'agent, les seves eines…).
|
|
165
|
+
function killTree(pid) {
|
|
166
|
+
if (!pid) return;
|
|
167
|
+
try {
|
|
168
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => {});
|
|
169
|
+
} catch {
|
|
170
|
+
/* ja ha acabat */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
125
174
|
function shQuote(s) {
|
|
126
175
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
127
176
|
}
|
|
@@ -141,4 +190,4 @@ function tryJson(line) {
|
|
|
141
190
|
}
|
|
142
191
|
}
|
|
143
192
|
|
|
144
|
-
module.exports = { which, version, run, childEnv, tryJson, stripAnsi, searchPath, shQuote };
|
|
193
|
+
module.exports = { which, version, run, childEnv, tryJson, stripAnsi, searchPath, shQuote, command, cmdQuote, killTree };
|
package/src/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Servei del sistema perquè nio arrenqui sol amb l'ordinador (systemd a Linux, launchd a macOS
|
|
2
|
+
// Servei del sistema perquè nio arrenqui sol amb l'ordinador (systemd a Linux, launchd a macOS i, a Windows,
|
|
3
|
+
// un script a la carpeta Inici que l'engega amagat en iniciar sessió i el torna a obrir si es tanca).
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const os = require('os');
|
|
5
6
|
const path = require('path');
|
|
@@ -66,8 +67,37 @@ ${args}
|
|
|
66
67
|
`;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
// Windows: no cal ser administrador. L'script fa de supervisor: engega nio sense finestra, n'escriu la sortida
|
|
71
|
+
// a ~/.nio/nio.log i, si es tanca, el torna a obrir al cap de 3 segons.
|
|
72
|
+
function windowsStartup() {
|
|
73
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'nio.vbs');
|
|
74
|
+
}
|
|
75
|
+
function windowsVbs({ roots }) {
|
|
76
|
+
const qv = (s) => String(s).replace(/"/g, '""'); // dins d'una cadena de VBScript
|
|
77
|
+
const arg = (s) => `"${s}"`;
|
|
78
|
+
// cmd /s /c "…": treu les cometes de fora i executa la resta tal qual (amb la redirecció al registre).
|
|
79
|
+
const line = qv(`cmd /d /s /c "${[process.execPath, entry(), '--no-open', ...roots].map(arg).join(' ')} >> ${arg(path.join(HOME, 'nio.log'))} 2>&1"`);
|
|
80
|
+
return `' nio · s'engega en iniciar sessió (nio service uninstall per treure-ho)
|
|
81
|
+
Set sh = CreateObject("WScript.Shell")
|
|
82
|
+
Set env = sh.Environment("PROCESS")
|
|
83
|
+
env("NO_COLOR") = "1"
|
|
84
|
+
env("PATH") = "${qv(searchPath().join(';'))}"
|
|
85
|
+
${process.env.NIO_HOME ? `env("NIO_HOME") = "${qv(process.env.NIO_HOME)}"
|
|
86
|
+
` : ''}sh.CurrentDirectory = "${qv(roots[0] || os.homedir())}"
|
|
87
|
+
Do
|
|
88
|
+
sh.Run "${line}", 0, True
|
|
89
|
+
WScript.Sleep 3000
|
|
90
|
+
Loop
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
// Atura el supervisor i nio (els processos que en porten la ruta a la línia d'ordres).
|
|
94
|
+
function windowsStop() {
|
|
95
|
+
const ps = `Get-CimInstance Win32_Process | Where-Object { ($_.Name -eq 'wscript.exe' -and $_.CommandLine -like '*nio.vbs*') -or ($_.Name -eq 'node.exe' -and $_.CommandLine -like '*${entry().replace(/'/g, "''")}*--no-open*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`;
|
|
96
|
+
sh('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
function sh(cmd, args) {
|
|
70
|
-
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
100
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true });
|
|
71
101
|
return { ok: r.status === 0, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
|
|
72
102
|
}
|
|
73
103
|
|
|
@@ -85,7 +115,18 @@ function install({ roots, log = console.log }) {
|
|
|
85
115
|
if (!r.ok) throw new Error(`launchctl no l’ha pogut carregar: ${r.out}`);
|
|
86
116
|
return { file, logs: `tail -f ${path.join(HOME, 'nio.log')}` };
|
|
87
117
|
}
|
|
88
|
-
if (process.platform
|
|
118
|
+
if (process.platform === 'win32') {
|
|
119
|
+
const file = windowsStartup();
|
|
120
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
121
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
122
|
+
windowsStop();
|
|
123
|
+
fs.writeFileSync(file, windowsVbs({ roots }));
|
|
124
|
+
const child = require('child_process').spawn('wscript.exe', ['//B', '//Nologo', file], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
125
|
+
child.on('error', () => {});
|
|
126
|
+
child.unref();
|
|
127
|
+
return { file, logs: `Get-Content -Wait "${path.join(HOME, 'nio.log')}"` };
|
|
128
|
+
}
|
|
129
|
+
if (process.platform !== 'linux') throw new Error('El servei automàtic només funciona a Linux (systemd), macOS (launchd) i Windows.');
|
|
89
130
|
if (!sh('systemctl', ['--version']).ok) throw new Error('Aquest sistema no té systemd.');
|
|
90
131
|
const system = process.getuid && process.getuid() === 0;
|
|
91
132
|
const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
|
|
@@ -105,6 +146,12 @@ function install({ roots, log = console.log }) {
|
|
|
105
146
|
}
|
|
106
147
|
|
|
107
148
|
function uninstall() {
|
|
149
|
+
if (process.platform === 'win32') {
|
|
150
|
+
const file = windowsStartup();
|
|
151
|
+
windowsStop();
|
|
152
|
+
fs.rmSync(file, { force: true });
|
|
153
|
+
return file;
|
|
154
|
+
}
|
|
108
155
|
if (process.platform === 'darwin') {
|
|
109
156
|
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
110
157
|
sh('launchctl', ['unload', '-w', file]);
|
|
@@ -120,4 +167,4 @@ function uninstall() {
|
|
|
120
167
|
return file;
|
|
121
168
|
}
|
|
122
169
|
|
|
123
|
-
module.exports = { install, uninstall, systemdUnit, launchdPlist };
|
|
170
|
+
module.exports = { install, uninstall, systemdUnit, launchdPlist, windowsVbs };
|
package/src/telegram.js
CHANGED
|
@@ -86,7 +86,13 @@ class Telegram {
|
|
|
86
86
|
signal: this.ctrl.signal,
|
|
87
87
|
});
|
|
88
88
|
const j = await r.json();
|
|
89
|
-
if (!j.ok)
|
|
89
|
+
if (!j.ok) {
|
|
90
|
+
// 409: un altre ordinador (o programa) llegeix el mateix bot. Telegram només en deixa un per bot.
|
|
91
|
+
if (j.error_code === 409 && !this.conflict) this.audit.add({ type: 'telegram', what: 'Un altre ordinador fa servir aquest mateix bot: cada ordinador en necessita un de propi' });
|
|
92
|
+
if (j.error_code === 409) this.conflict = Date.now();
|
|
93
|
+
throw new Error(j.description || r.status);
|
|
94
|
+
}
|
|
95
|
+
this.conflict = 0;
|
|
90
96
|
for (const u of j.result) {
|
|
91
97
|
this.offset = u.update_id + 1;
|
|
92
98
|
await this.onUpdate(u).catch((e) => this.audit.add({ type: 'telegram', what: `Error: ${e.message}` }));
|
|
@@ -247,6 +253,7 @@ class Telegram {
|
|
|
247
253
|
configured: !!t.token,
|
|
248
254
|
bot: this.me ? { username: this.me.username, name: this.me.first_name } : null,
|
|
249
255
|
running: this.running,
|
|
256
|
+
conflict: !!this.conflict && Date.now() - this.conflict < 5 * 60e3,
|
|
250
257
|
allowed: t.allowed || [],
|
|
251
258
|
pending: (t.pending || []).filter((p) => !this.allowed(p.id)),
|
|
252
259
|
agent: t.agent || null,
|
|
@@ -281,6 +288,16 @@ class Telegram {
|
|
|
281
288
|
t.token = prev;
|
|
282
289
|
throw httpErr(400, `Telegram no accepta aquest testimoni: ${e.message}`);
|
|
283
290
|
}
|
|
291
|
+
// Un bot només el pot llegir un ordinador alhora: si ja el fa servir un altre, cal un bot nou per a aquest.
|
|
292
|
+
// (Sense «offset», aquesta consulta no dona per llegit cap missatge.)
|
|
293
|
+
if (tok !== prev) {
|
|
294
|
+
const busy = await this.call('getUpdates', { timeout: 0, limit: 1 }).then(() => false, (e) => /conflict|409/i.test(e.message));
|
|
295
|
+
if (busy) {
|
|
296
|
+
t.token = prev;
|
|
297
|
+
this.me = null;
|
|
298
|
+
throw httpErr(409, 'Aquest bot ja el fa servir un altre ordinador. Telegram només deixa llegir un bot des d’un lloc: crea’n un altre a @BotFather (/newbot) per a aquest ordinador.');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
284
301
|
this.stop();
|
|
285
302
|
this.offset = undefined;
|
|
286
303
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// nio dins l'app d'Android: l'únic lloc on la web s'adapta a l'app.
|
|
2
|
+
// La resta del codi fa servir les APIs estàndard del navegador i no sap que és dins una app; aquí es tradueixen
|
|
3
|
+
// a la part nativa (window.NioAndroid, a android/src/cat/nio/app/MainActivity.java) les que el WebView no té.
|
|
4
|
+
// Fora de l'app, aquest fitxer no fa res. Per afegir-hi una cosa nova: un mètode a Bridge (Java) i el pont aquí.
|
|
5
|
+
(() => {
|
|
6
|
+
const NA = window.NioAndroid;
|
|
7
|
+
if (!NA) return;
|
|
8
|
+
document.documentElement.classList.add('android');
|
|
9
|
+
|
|
10
|
+
// Descàrregues (<a download href="blob:…">: exportar la pissarra, desar una captura…) → carpeta Baixades del mòbil.
|
|
11
|
+
const base64 = (blob) => new Promise((ok, ko) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = ko; r.readAsDataURL(blob); });
|
|
12
|
+
async function save(href, name) {
|
|
13
|
+
try { const blob = await (await fetch(href)).blob(); NA.saveFile(name || 'nio', blob.type || 'application/octet-stream', await base64(blob)); }
|
|
14
|
+
catch { NA.saveFile('', '', ''); } // l'app avisa que no s'ha pogut desar
|
|
15
|
+
}
|
|
16
|
+
const isBlob = (a) => a.hasAttribute('download') && /^(blob|data):/.test(a.href);
|
|
17
|
+
if (NA.saveFile) { // les versions de l'app anteriors a la 1.0.2 no ho tenen
|
|
18
|
+
const click = HTMLAnchorElement.prototype.click;
|
|
19
|
+
HTMLAnchorElement.prototype.click = function () { if (isBlob(this)) return void save(this.href, this.getAttribute('download')); return click.call(this); };
|
|
20
|
+
document.addEventListener('click', (e) => { const a = e.target.closest?.('a[download]'); if (a && isBlob(a)) { e.preventDefault(); save(a.href, a.getAttribute('download')); } }, true);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Copiar: el porta-retalls del WebView no sempre deixa escriure; el d'Android sí.
|
|
24
|
+
if (!NA.copy) { /* app antiga */ } else if (navigator.clipboard) navigator.clipboard.writeText = (t) => Promise.resolve(NA.copy(String(t)));
|
|
25
|
+
else Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => Promise.resolve(NA.copy(String(t))) }, configurable: true });
|
|
26
|
+
|
|
27
|
+
// Pantalla encesa: el WebView no sempre té navigator.wakeLock; l'app manté la pantalla encesa mentre hi ha algun bloqueig.
|
|
28
|
+
if (NA.keepAwake) {
|
|
29
|
+
const held = new Set();
|
|
30
|
+
const lock = { request: async () => {
|
|
31
|
+
const s = { released: false, type: 'screen', onrelease: null, addEventListener() {}, removeEventListener() {},
|
|
32
|
+
async release() { if (s.released) return; s.released = true; held.delete(s); if (!held.size) NA.keepAwake(false); s.onrelease?.(); } };
|
|
33
|
+
held.add(s); NA.keepAwake(true); return s;
|
|
34
|
+
} };
|
|
35
|
+
try { Object.defineProperty(navigator, 'wakeLock', { value: lock, configurable: true }); } catch { /* es queda el del WebView */ }
|
|
36
|
+
}
|
|
37
|
+
})();
|
package/web/app/app.js
CHANGED
|
@@ -224,7 +224,7 @@ async function pollLoop(ctrl) {
|
|
|
224
224
|
const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
|
|
225
225
|
if (ctrl.signal.aborted) return;
|
|
226
226
|
state.connected = true; state.lastError = null; alive = Date.now();
|
|
227
|
-
if (first) { first = false; state.synced = true; const r = state.route; state.sessions = new Map(list.map((x) => [x.id, x])); state.approvals = new Map(aps.map((a) => [a.id, a])); if (r.name === 'session') openSession(r.id); else render(); }
|
|
227
|
+
if (first) { first = false; state.synced = true; const r = state.route; state.sessions = new Map(list.map((x) => [x.id, x])); state.approvals = new Map(aps.map((a) => [a.id, a])); if (r.name === 'session') openSession(r.id); else render(); saveSnap(); }
|
|
228
228
|
else {
|
|
229
229
|
const r = state.route, seen = new Set();
|
|
230
230
|
for (const x of list) {
|
|
@@ -290,14 +290,53 @@ async function relocate(i) {
|
|
|
290
290
|
} catch { return false; }
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/* ---------- Instantània de cada ordinador
|
|
294
|
+
En canviar d'ordinador es pinta a l'instant el que ja en sabíem (sessions, aprovacions, ús i info) i es posa al dia
|
|
295
|
+
quan arriba l'«hello». Mentre la llista d'ordinadors és oberta, els altres es refresquen en segon pla. */
|
|
296
|
+
const SNAP_KEY = 'nio.snap';
|
|
297
|
+
const snaps = new Map(Object.entries(LS.get(SNAP_KEY, {}) || {}));
|
|
298
|
+
let snapT = 0;
|
|
299
|
+
const persistSnaps = () => LS.set(SNAP_KEY, Object.fromEntries([...snaps].filter(([k]) => store.machines.some((m) => mkey(m) === k))));
|
|
300
|
+
function saveSnap() {
|
|
301
|
+
const m = machine(); if (!m || isDemo(m) || !state.synced) return;
|
|
302
|
+
clearTimeout(snapT);
|
|
303
|
+
snapT = setTimeout(() => {
|
|
304
|
+
if (mkey(machine()) !== mkey(m) || !state.synced) return;
|
|
305
|
+
snaps.set(mkey(m), { t: Date.now(), info: state.info, usage: state.usage || null, approvals: [...state.approvals.values()], sessions: [...state.sessions.values()].sort((a, b) => b.updated - a.updated).slice(0, 80) });
|
|
306
|
+
persistSnaps();
|
|
307
|
+
}, 500);
|
|
308
|
+
}
|
|
309
|
+
function restoreSnap(m) {
|
|
310
|
+
const s = snaps.get(mkey(m)); if (!s) return false;
|
|
311
|
+
state.info = s.info || null; state.usage = s.usage || null;
|
|
312
|
+
state.sessions = new Map((s.sessions || []).map((x) => [x.id, x])); state.approvals = new Map((s.approvals || []).map((a) => [a.id, a]));
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
const snapBusy = new Set();
|
|
316
|
+
async function refreshSnaps() {
|
|
317
|
+
await Promise.all(store.machines.map(async (m) => {
|
|
318
|
+
const k = mkey(m);
|
|
319
|
+
if (m === machine() || isDemo(m) || snapBusy.has(k) || Date.now() - (snaps.get(k)?.t || 0) < 15000) return;
|
|
320
|
+
snapBusy.add(k);
|
|
321
|
+
try {
|
|
322
|
+
const get = (p) => fetch(`${m.url}${p}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(6000) }).then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); });
|
|
323
|
+
const [info, sessions, approvals, usage] = await Promise.all([get('/api/info'), get('/api/sessions'), get('/api/approvals'), get('/api/usage').catch(() => null)]);
|
|
324
|
+
if (m === machine()) return;
|
|
325
|
+
snaps.set(k, { t: Date.now(), info, usage, approvals, sessions: sessions.sort((a, b) => b.updated - a.updated).slice(0, 80) }); persistSnaps();
|
|
326
|
+
} catch { /* no hi arribo: es queda la d'abans */ } finally { snapBusy.delete(k); }
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
|
|
293
330
|
async function connect() {
|
|
294
331
|
streamCtrl?.abort(); state.synced = false;
|
|
295
332
|
const m = machine();
|
|
296
333
|
if (!m) { state.connected = false; return render(); }
|
|
297
334
|
const ctrl = new AbortController(); streamCtrl = ctrl;
|
|
298
335
|
try {
|
|
299
|
-
|
|
300
|
-
const res = await fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
|
|
336
|
+
// La info i el flux en paral·lel: el canvi d'ordinador no espera dues anades i tornades seguides.
|
|
337
|
+
const [info, res] = await Promise.all([api('/api/info'), fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' })]);
|
|
338
|
+
if (ctrl.signal.aborted) return;
|
|
339
|
+
state.info = info;
|
|
301
340
|
if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
|
|
302
341
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
303
342
|
state.connected = true; state.lastError = null; retry = 0; alive = Date.now();
|
|
@@ -372,6 +411,7 @@ function renameMachine(host, id) {
|
|
|
372
411
|
function handle(msg) {
|
|
373
412
|
const r = state.route;
|
|
374
413
|
callCtl?.onEvent(msg);
|
|
414
|
+
if (/^(session|removed|approval|usage)$/.test(msg.type)) saveSnap();
|
|
375
415
|
switch (msg.type) {
|
|
376
416
|
case 'device':
|
|
377
417
|
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
@@ -382,6 +422,7 @@ function handle(msg) {
|
|
|
382
422
|
state.approvals = new Map(msg.approvals.map((a) => [a.id, a])); state.usage = msg.usage || state.usage; notesCtl?.reload();
|
|
383
423
|
state.loaded.clear(); state.events.clear();
|
|
384
424
|
if (r.name === 'session') openSession(r.id); else render();
|
|
425
|
+
saveSnap(); if (store.machines.length > 1) setTimeout(refreshSnaps, 4000);
|
|
385
426
|
break;
|
|
386
427
|
case 'session':
|
|
387
428
|
state.sessions.set(msg.session.id, msg.session);
|
|
@@ -493,8 +534,10 @@ async function pairAndAdd(pair, { msg = 'Ordinador connectat', after } = {}) {
|
|
|
493
534
|
}
|
|
494
535
|
// Passa a un altre ordinador de la llista i es queda a la mateixa secció.
|
|
495
536
|
function switchMachine(i) {
|
|
496
|
-
closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; nodeVoice = null;
|
|
497
|
-
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
|
|
537
|
+
closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; tgOthers = []; nodeVoice = null;
|
|
538
|
+
state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.usage = null; state.lastError = null;
|
|
539
|
+
restoreSnap(machine()); // el que ja en sabíem, a l'instant; l'«hello» ho posa al dia
|
|
540
|
+
if (state.route.name === 'notes' && !notesCtl?.open) lastName = null; // les notes de l'altre ordinador es tornen a muntar (a l'instant si ja les coneixíem)
|
|
498
541
|
connect();
|
|
499
542
|
if (state.route.name === 'session') go('#/sessions'); else render();
|
|
500
543
|
}
|
|
@@ -576,7 +619,7 @@ function renderChrome() {
|
|
|
576
619
|
const s = r.name === 'session' ? state.sessions.get(r.id) : null;
|
|
577
620
|
$('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
|
|
578
621
|
$('#mRight').innerHTML = r.name === 'session'
|
|
579
|
-
?
|
|
622
|
+
? `${usageChip(s?.agent, true)}<button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button>`
|
|
580
623
|
: `<a class="icon-btn" href="#/aprovacions" aria-label="${n ? `Aprovacions, ${n} pendents` : 'Aprovacions'}">${svg(ICON.bell)}${n ? `<span class="badge">${n}</span>` : ''}</a>`;
|
|
581
624
|
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
582
625
|
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
@@ -627,7 +670,12 @@ function render() {
|
|
|
627
670
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
628
671
|
const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
|
|
629
672
|
const isPhone = () => navigator.userAgentData?.mobile === true || /Android|iPhone|iPod|Mobile/i.test(navigator.userAgent) || (isTouch && matchMedia('(max-width: 1024px)').matches);
|
|
630
|
-
const
|
|
673
|
+
const SH_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh', WIN_CMD = 'irm https://www.nio.cat/install.ps1 | iex';
|
|
674
|
+
// En un PC amb Windows, l'ordre de PowerShell; a la resta (i al mòbil, que no sap quin ordinador tens), la de Mac i Linux.
|
|
675
|
+
const isWindows = !isPhone() && (navigator.userAgentData?.platform === 'Windows' || /Windows/.test(navigator.userAgent));
|
|
676
|
+
const INSTALL_CMD = isWindows ? WIN_CMD : SH_CMD;
|
|
677
|
+
const OPEN_TERM = isWindows ? 'Obre PowerShell: prem la tecla Windows i escriu «PowerShell».' : 'Obre el Terminal. Al Mac, prem ⌘ Espai i escriu «Terminal».';
|
|
678
|
+
const OTHER_OS = isWindows ? `Windows. A Mac o Linux: <span class="mono">${SH_CMD}</span>` : `Mac o Linux. A Windows, al PowerShell: <span class="mono">${WIN_CMD}</span>`;
|
|
631
679
|
const cmdBox = (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>`;
|
|
632
680
|
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
633
681
|
const reach = new Map();
|
|
@@ -762,9 +810,9 @@ function viewComputers() {
|
|
|
762
810
|
if (!ms.length) {
|
|
763
811
|
const phone = isPhone();
|
|
764
812
|
const steps = phone
|
|
765
|
-
? [['Enganxa-la a l’ordinador', 'Al Terminal. Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Surt al navegador de l’ordinador quan acaba. També hi ha un codi.'], ['Dona feina a un agent', 'Tria l’agent i la carpeta, i mira com treballa des d’aquí.']]
|
|
766
|
-
: [['Obre el Terminal', 'Al Mac, ⌘ Espai i escriu «Terminal». A Linux, Ctrl Alt T.'], ['Enganxa l’ordre', 'Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Aquesta web es connecta sola i et mostra el QR del mòbil.']];
|
|
767
|
-
return `${head}<div class="panel empty first-run">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><p>${phone ? 'nio s’instal·la a l’ordinador amb una sola ordre. Copia-la i enganxa-la allà.' : 'Una sola ordre instal·la nio en aquest ordinador i ho deixa tot a punt.'}</p>${cmdBox(INSTALL_CMD)}<ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}${phone ? 'Tinc un codi o un QR' : 'Afegeix ordinador'}</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small"
|
|
813
|
+
? [['Enganxa-la a l’ordinador', 'Al Terminal (a Windows, a PowerShell). Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Surt al navegador de l’ordinador quan acaba. També hi ha un codi.'], ['Dona feina a un agent', 'Tria l’agent i la carpeta, i mira com treballa des d’aquí.']]
|
|
814
|
+
: [isWindows ? ['Obre PowerShell', 'Prem la tecla Windows i escriu «PowerShell».'] : ['Obre el Terminal', 'Al Mac, ⌘ Espai i escriu «Terminal». A Linux, Ctrl Alt T.'], ['Enganxa l’ordre', 'Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Aquesta web es connecta sola i et mostra el QR del mòbil.']];
|
|
815
|
+
return `${head}<div class="panel empty first-run">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><p>${phone ? 'nio s’instal·la a l’ordinador amb una sola ordre. Copia-la i enganxa-la allà.' : 'Una sola ordre instal·la nio en aquest ordinador i ho deixa tot a punt.'}</p>${cmdBox(INSTALL_CMD)}<ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}${phone ? 'Tinc un codi o un QR' : 'Afegeix ordinador'}</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small">${OTHER_OS}. Per provar-lo sense instal·lar res, <span class="mono">npx nio-cat</span>.</p></div>`;
|
|
768
816
|
}
|
|
769
817
|
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
770
818
|
<div class="grid2 one">
|
|
@@ -851,7 +899,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
851
899
|
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
852
900
|
if (d && saved >= 0) set('saved', `${intro('Aquest ordinador ja és a la llista', 'El tens connectat com a «' + esc(d.name) + '».')}<div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre sessions</button></div>`);
|
|
853
901
|
else if (d) set('found', `${intro('nio ja funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Escriu això al Terminal i s’obrirà el panell ja connectat:')}${cmdBox('nio pair')}`);
|
|
854
|
-
else set('none', `${intro('Instal·la nio en aquest ordinador', 'Una sola ordre ho deixa tot a punt i engegat, també quan reinicies.')}${todo([
|
|
902
|
+
else set('none', `${intro('Instal·la nio en aquest ordinador', 'Una sola ordre ho deixa tot a punt i engegat, també quan reinicies.')}${todo([OPEN_TERM, `Enganxa aquesta ordre i prem Retorn:${cmdBox(INSTALL_CMD)}`, 'Quan acabi, aquesta pàgina es connectarà sola.'])}${live('Esperant nio…')}<p class="hint">${OTHER_OS}. Si ja el tens i està aturat, escriu <span class="mono">nio</span> al ${isWindows ? 'PowerShell' : 'Terminal'}.</p>${store.machines.length ? '' : '<div class="or"><span>o</span></div><button class="btn block" type="button" data-close data-act="demo">Prova una demo, sense instal·lar res</button>'}`);
|
|
855
903
|
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
856
904
|
};
|
|
857
905
|
const runSsh = async (target) => {
|
|
@@ -905,6 +953,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
905
953
|
}
|
|
906
954
|
function machinesSheet() {
|
|
907
955
|
const ms = store.machines;
|
|
956
|
+
refreshSnaps();
|
|
908
957
|
openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
|
|
909
958
|
<div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
|
|
910
959
|
}
|
|
@@ -1327,12 +1376,14 @@ function renderSession() {
|
|
|
1327
1376
|
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
1328
1377
|
// Ús de la subscripció de l'agent (5 h i setmana), com a esfis. Només si l'agent l'ha informat.
|
|
1329
1378
|
const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000 - Date.now() > 864e5 ? 'short' : undefined, hour: '2-digit', minute: '2-digit' });
|
|
1330
|
-
|
|
1379
|
+
// mini: la versió de la barra de dalt del mòbil (l'anell i la finestra més plena; tocant-la, el detall).
|
|
1380
|
+
function usageChip(agent, mini) {
|
|
1331
1381
|
const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
|
|
1332
1382
|
const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
|
|
1333
1383
|
// Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
|
|
1334
1384
|
const short = (l) => (l === 'setmana' ? '7d' : l.replace(/\s+/g, '')), top = Math.max(...u.windows.map((w) => w.used)), pct = Math.round(top * 100);
|
|
1335
1385
|
const ring = `<svg class="ico ring" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" class="rt"/><circle cx="12" cy="12" r="8.5" pathLength="100" stroke-dasharray="${Math.max(pct, 2)} 100" transform="rotate(-90 12 12)" class="rv"/></svg>`;
|
|
1386
|
+
if (mini) { const w = u.windows.find((x) => x.used === top); return `<button class="chip mono usage mini" type="button" data-act="usage" data-hi="${top >= 0.8 ? 1 : 0}" aria-label="${esc(`Ús de la subscripció. ${tip.replace(/\n/g, '. ')}`)}">${ring}<span class="uw" data-hi="${top >= 0.8 ? 1 : 0}">${esc(short(w.label))} ${pct}%</span></button>`; }
|
|
1336
1387
|
return `<span class="chip mono usage" data-hi="${top >= 0.8 ? 1 : 0}" title="${esc(`Ús de la subscripció\n${tip}`)}">${ring}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(short(w.label))} (${Math.round(w.used * 100)}%)</span>`).join('')}</span>`;
|
|
1337
1388
|
}
|
|
1338
1389
|
function renderSessionHead() {
|
|
@@ -2551,9 +2602,16 @@ document.addEventListener('click', async (e) => {
|
|
|
2551
2602
|
|
|
2552
2603
|
/* ---------------------------------------------------------------- Telegram (bot propi, configurat a l'ordinador) */
|
|
2553
2604
|
let tgState;
|
|
2605
|
+
let tgOthers = []; // bots dels altres ordinadors: [{ name, bot, allowed }]
|
|
2554
2606
|
async function loadTelegram() {
|
|
2555
2607
|
try { tgState = await api('/api/telegram'); } catch { tgState = null; }
|
|
2556
2608
|
const box = $('#tgPanel'); if (box) box.innerHTML = telegramPanel();
|
|
2609
|
+
const mine = machine(), others = store.machines.filter((m) => m !== mine && mkey(m) !== mkey(mine) && !isDemo(m));
|
|
2610
|
+
const got = await Promise.all(others.map((m) => fetch(`${m.url}/api/telegram`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) })
|
|
2611
|
+
.then((r) => (r.ok ? r.json() : null)).then((t) => (t?.configured ? { name: m.name, bot: t.bot?.username || '', allowed: t.allowed || [] } : null)).catch(() => null)));
|
|
2612
|
+
if (machine() !== mine && mkey(machine()) !== mkey(mine)) return;
|
|
2613
|
+
tgOthers = got.filter(Boolean);
|
|
2614
|
+
if (tgOthers.length && $('#tgPanel')) $('#tgPanel').innerHTML = telegramPanel();
|
|
2557
2615
|
}
|
|
2558
2616
|
function telegramPanel() {
|
|
2559
2617
|
if (!machine()) return '';
|
|
@@ -2562,9 +2620,11 @@ function telegramPanel() {
|
|
|
2562
2620
|
if (t === null) return '<section class="panel"><header><h2>Telegram</h2></header><div class="body"><p class="muted small">Aquest ordinador encara no té Telegram (cal nio 0.7 o més nou).</p></div></section>';
|
|
2563
2621
|
if (!t.configured) return `<section class="panel"><header><h2>Telegram</h2></header><div class="body stack">
|
|
2564
2622
|
<p class="small">Parla amb els agents d’aquest ordinador des de Telegram: instruccions, notes de veu, avisos i aprovacions amb botons. El bot és teu i el testimoni es queda a l’ordinador.</p>
|
|
2565
|
-
|
|
2623
|
+
${tgOthers.length ? `<p class="small tg-note">Cada ordinador necessita <b>el seu bot</b>: Telegram només deixa llegir un bot des d’un lloc. ${tgOthers.map((o) => `<b>${esc(o.name)}</b> ja fa servir ${o.bot ? '@' + esc(o.bot) : 'un bot'}`).join(', ')}; crea’n un altre per a aquest${tgOthers.some((o) => o.allowed.length) ? ' i els teus xats hi quedaran autoritzats sols' : ''}.</p>` : ''}
|
|
2624
|
+
<ol class="muted small" style="padding-left:1.2em"><li>A Telegram, obre <b>@BotFather</b> i envia <span class="mono">/newbot</span>${tgOthers.length ? ' (un nom diferent, p. ex. amb el nom de l’ordinador)' : ''}.</li><li>Copia el testimoni que et dona i enganxa’l aquí.</li><li>Escriu qualsevol cosa al teu bot i torna aquí per autoritzar el teu xat.</li></ol>
|
|
2566
2625
|
<form class="row" id="tgForm" style="flex-wrap:nowrap"><input type="password" id="tgToken" placeholder="123456:ABC…" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Testimoni del bot"><button class="btn ink" type="submit">Connecta</button></form></div></section>`;
|
|
2567
2626
|
return `<section class="panel"><header><h2>Telegram</h2><span class="chip ok"><i class="dot on"></i>${esc(t.bot ? '@' + t.bot.username : 'Connectat')}</span></header><div class="body stack">
|
|
2627
|
+
${t.conflict ? '<p class="small form-error">Un altre ordinador llegeix aquest mateix bot i es reparteixen els missatges. Cada ordinador necessita el seu: desconnecta’l d’un dels dos i crea’n un altre a @BotFather.</p>' : ''}
|
|
2568
2628
|
${t.pending.length ? `<div class="stack"><b class="small">Volen parlar amb el bot</b>${t.pending.map((p) => `<div class="agent-row"><span class="grow"><b>${esc(p.name)}</b><div class="muted small">${p.username ? '@' + esc(p.username) + ' · ' : ''}${ago(p.t)} · xat ${esc(p.id)}</div></span><button class="btn sm ink" data-tg-allow="${esc(p.id)}" data-name="${esc(p.name)}" type="button">Autoritza</button></div>`).join('')}<p class="muted small">Autoritza només els xats que reconeguis. La resta s’ignoren.</p></div>` : ''}
|
|
2569
2629
|
<div class="stack"><b class="small">Xats autoritzats</b>${t.allowed.length ? t.allowed.map((a) => `<div class="agent-row"><span class="grow"><b>${esc(a.name)}</b><div class="muted small">xat ${esc(a.id)}</div></span><button class="btn sm danger" data-tg-drop="${esc(a.id)}" type="button">Treu</button></div>`).join('') : `<p class="muted small">Encara cap. Escriu al bot ${t.bot ? '<b>@' + esc(t.bot.username) + '</b>' : ''} i el teu xat apareixerà aquí.</p>`}</div>
|
|
2570
2630
|
<div class="grid-2"><label class="f"><span>Agent</span><select id="tgAgent">${['claude', 'codex'].map((a) => `<option value="${a}" ${t.agent === a ? 'selected' : ''}>${AGENT_NAMES[a]}</option>`).join('')}</select></label>
|
|
@@ -2575,7 +2635,10 @@ function telegramPanel() {
|
|
|
2575
2635
|
}
|
|
2576
2636
|
document.addEventListener('submit', async (e) => {
|
|
2577
2637
|
if (e.target.id !== 'tgForm') return; e.preventDefault();
|
|
2578
|
-
try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message,
|
|
2638
|
+
try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message, 7000); }
|
|
2639
|
+
// Els xats que ja parlen amb els bots dels altres ordinadors també poden parlar amb aquest.
|
|
2640
|
+
const known = new Map(tgOthers.flatMap((o) => o.allowed).map((a) => [String(a.id), a]));
|
|
2641
|
+
if (tgState?.configured) for (const a of known.values()) if (!tgState.allowed.some((x) => String(x.id) === String(a.id))) tgState = await api('/api/telegram/allow', { method: 'POST', body: { id: Number(a.id), name: a.name } }).catch(() => tgState);
|
|
2579
2642
|
$('#tgPanel').innerHTML = telegramPanel();
|
|
2580
2643
|
});
|
|
2581
2644
|
document.addEventListener('change', async (e) => {
|
|
@@ -2790,6 +2853,7 @@ document.addEventListener('click', async (e) => {
|
|
|
2790
2853
|
case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
|
|
2791
2854
|
case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
|
|
2792
2855
|
case 'session-more': return sessionOptions(state.route.id);
|
|
2856
|
+
case 'usage': { const u = state.usage?.[state.sessions.get(state.route.id)?.agent]; if (u?.windows) toast(`Ús de la subscripció · ${u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` (es reinicia ${hhmm(w.resetsAt)})` : ''}`).join(' · ')}`, 5000); return; }
|
|
2793
2857
|
case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
|
|
2794
2858
|
case 'push': return enablePush();
|
|
2795
2859
|
case 'push-off': return disablePush();
|
|
@@ -3100,7 +3164,7 @@ if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
|
|
|
3100
3164
|
}
|
|
3101
3165
|
setNav(false);
|
|
3102
3166
|
if (!checkPairHash()) state.route = parseRoute();
|
|
3103
|
-
if (machine()) connect();
|
|
3167
|
+
if (machine()) { restoreSnap(machine()); connect(); }
|
|
3104
3168
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
3105
3169
|
if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
|
|
3106
3170
|
|
package/web/app/index.html
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
34
34
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Caveat:wght@500;600&display=swap" rel="stylesheet">
|
|
35
35
|
<script>/* App d'Android: la pàgina va per sota de les barres del sistema; l'app en diu la mida abans de pintar. */try{var i=JSON.parse(NioAndroid.insets()),d=document.documentElement;d.style.setProperty('--safe-t',i.t+'px');d.style.setProperty('--safe-b',i.b+'px');d.classList.add('android')}catch(e){}</script>
|
|
36
|
+
<script src="/app/android.js"></script>
|
|
36
37
|
<link rel="stylesheet" href="/bot.css">
|
|
37
38
|
<link rel="stylesheet" href="/board.css">
|
|
38
39
|
<link rel="stylesheet" href="/app/style.css">
|
package/web/app/notes.css
CHANGED
|
@@ -145,6 +145,12 @@ body:has(.nt-ov) { overflow: hidden; }
|
|
|
145
145
|
.nt-more-sheet button.danger { color: var(--red); }
|
|
146
146
|
.nt-more-sheet button.muted { color: var(--fg-muted); text-align: center; }
|
|
147
147
|
|
|
148
|
+
/* Al mòbil, el panell «Més» i els desplegables són fulls de baix que segueixen el dit: el navegador no s'ho queda per desplaçar. */
|
|
149
|
+
.nt-more-sheet, .nt-pop-sheet { touch-action: none; }
|
|
150
|
+
.nt-pop.nt-pop-sheet { position: absolute; z-index: 6; left: 0 !important; right: 0; bottom: 0 !important; top: auto; min-width: 0; max-width: none; max-height: 75% !important; overflow: auto; overscroll-behavior: contain; padding: 6px 12px calc(14px + var(--safe-b)); border: 0; border-radius: 18px 18px 0 0; box-shadow: 0 -8px 30px rgba(39,34,36,.14); animation: nt-sheet-up .28s cubic-bezier(.22,1,.36,1); }
|
|
151
|
+
.nt-pop-sheet input, .nt-pop-sheet select { touch-action: manipulation; }
|
|
152
|
+
@keyframes nt-sheet-up { from { transform: translateY(100%); } }
|
|
153
|
+
|
|
148
154
|
/* finestretes */
|
|
149
155
|
.nt-pop { position: absolute; z-index: 5; min-width: 220px; max-width: min(340px, calc(100% - 16px)); padding: 8px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 18px 50px -14px rgba(39,34,36,.5); animation: nt-pop .18s var(--ease); transform-origin: bottom left; }
|
|
150
156
|
@keyframes nt-pop { from { opacity: 0; transform: translateY(6px) scale(.97); } }
|
package/web/app/notes.js
CHANGED
|
@@ -54,6 +54,7 @@ export function createNotes(ctx) {
|
|
|
54
54
|
let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
|
|
55
55
|
let ed = null, aborter = null;
|
|
56
56
|
const historyNotes = new Map();
|
|
57
|
+
const byMachine = new Map(); // ordinador -> notes que en coneixem
|
|
57
58
|
const pendingSaves = new Set();
|
|
58
59
|
let loadError = '';
|
|
59
60
|
const machineKey = () => ctx.machine()?.id || ctx.machine()?.url;
|
|
@@ -87,6 +88,7 @@ export function createNotes(ctx) {
|
|
|
87
88
|
const r = await api('/api/notes');
|
|
88
89
|
if (requestedMachine !== machineKey()) { loading = false; if (root) load(); return; }
|
|
89
90
|
notes = new Map(r.notes.map((n) => [n.id, n])); loaded = true;
|
|
91
|
+
byMachine.set(requestedMachine, notes);
|
|
90
92
|
for (const entry of pendingSaves) if (entry.id && entry.machineKey === machineKey()) notes.set(entry.id, entry.n);
|
|
91
93
|
if (ed?.id) notes.set(ed.id, ed.n);
|
|
92
94
|
}
|
|
@@ -101,7 +103,12 @@ export function createNotes(ctx) {
|
|
|
101
103
|
// ---------------------------------------------------------------- pàgina
|
|
102
104
|
function mount(el) {
|
|
103
105
|
root = el; aborter?.abort(); aborter = new AbortController();
|
|
104
|
-
if (notesMachine !== machineKey()) {
|
|
106
|
+
if (notesMachine !== machineKey()) {
|
|
107
|
+
// Les notes de l'ordinador d'abans es queden a la memòria: si hi tornes, surten a l'instant i es posen al dia.
|
|
108
|
+
if (loaded) byMachine.set(notesMachine, notes);
|
|
109
|
+
notesMachine = machineKey(); engines = null; q = ''; view = 'notes';
|
|
110
|
+
notes = byMachine.get(notesMachine) || new Map(); loaded = byMachine.has(notesMachine);
|
|
111
|
+
}
|
|
105
112
|
if (history.state?.nioNote) { const { nioNote, ...rest } = history.state; history.replaceState(rest, '', location.href); }
|
|
106
113
|
root.innerHTML = `<div class="nt">
|
|
107
114
|
<div class="nt-top">
|
|
@@ -491,11 +498,11 @@ export function createNotes(ctx) {
|
|
|
491
498
|
title.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ($('.nt-ed-text', c) || $('.nt-item input[type=text]', c))?.focus(); } });
|
|
492
499
|
c.addEventListener('click', onEditorClick);
|
|
493
500
|
drawBody(); drawAudio(); drawLabels(); drawMode();
|
|
494
|
-
bindMoreDrag($('#edMore', c), $('#edScrim', c));
|
|
501
|
+
bindMoreDrag($('#edMore', c), $('#edScrim', c), () => closeMore(true));
|
|
495
502
|
}
|
|
496
503
|
// El panell «Més» segueix el dit cap avall i es tanca si baixa prou o es llença; si no, torna a pujar.
|
|
497
504
|
let moreDraggedAt = 0;
|
|
498
|
-
function bindMoreDrag(sheet, scrim) {
|
|
505
|
+
function bindMoreDrag(sheet, scrim, close) {
|
|
499
506
|
let g = null;
|
|
500
507
|
sheet.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; g = { y0: e.clientY, dy: 0, pid: e.pointerId, pts: [[e.timeStamp, e.clientY]], moved: false }; }, { passive: true });
|
|
501
508
|
sheet.addEventListener('pointermove', (e) => {
|
|
@@ -512,7 +519,7 @@ export function createNotes(ctx) {
|
|
|
512
519
|
const d = g; g = null; if (!d.moved) return;
|
|
513
520
|
moreDraggedAt = Date.now();
|
|
514
521
|
const [t0, y0] = d.pts[0], [t1, y1] = d.pts[d.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
515
|
-
if (d.dy > sheet.offsetHeight * 0.3 || (v > 0.4 && d.dy > 12))
|
|
522
|
+
if (d.dy > sheet.offsetHeight * 0.3 || (v > 0.4 && d.dy > 12)) close();
|
|
516
523
|
else { sheet.style.transition = ''; scrim.style.transition = ''; sheet.style.transform = ''; scrim.style.opacity = ''; }
|
|
517
524
|
};
|
|
518
525
|
sheet.addEventListener('pointerup', end); sheet.addEventListener('pointercancel', end);
|
|
@@ -575,7 +582,7 @@ export function createNotes(ctx) {
|
|
|
575
582
|
async function onEditorClick(ev) {
|
|
576
583
|
const b = ev.target.closest('[data-e]'), c = ed?.card; if (!b || !ed) { if (ed?.pop && !ev.target.closest('.nt-pop')) closePop(); if (ed && !ev.target.closest('.nt-more-sheet')) closeMore(true); return; }
|
|
577
584
|
const a = b.dataset.e;
|
|
578
|
-
if (ev.target.closest('.nt-more-sheet') && Date.now() - moreDraggedAt < 350) return; // era un arrossegament, no un toc
|
|
585
|
+
if (ev.target.closest('.nt-more-sheet, .nt-pop-sheet') && Date.now() - moreDraggedAt < 350) return; // era un arrossegament, no un toc
|
|
579
586
|
if (a === 'more-close') return closeMore(true);
|
|
580
587
|
if (a !== 'more' && !ev.target.closest('.nt-more-sheet')) closeMore(true);
|
|
581
588
|
if (a === 'retry-save') { b.disabled = true; await flush(ed); b.disabled = false; return; }
|
|
@@ -624,10 +631,28 @@ export function createNotes(ctx) {
|
|
|
624
631
|
s.style.transform = 'translateY(105%)'; sc.style.opacity = '0';
|
|
625
632
|
setTimeout(hide, 220);
|
|
626
633
|
}
|
|
627
|
-
function closePop() {
|
|
634
|
+
function closePop(animated) {
|
|
635
|
+
const p = ed?.pop; if (!p) return; ed.pop = null;
|
|
636
|
+
const sc = p.previousElementSibling?.classList.contains('nt-pop-scrim') ? p.previousElementSibling : null;
|
|
637
|
+
if (!animated || reduce || !p.classList.contains('nt-pop-sheet')) { p.remove(); sc?.remove(); return; }
|
|
638
|
+
p.style.transition = 'transform .22s cubic-bezier(.4,0,1,1)'; p.style.transform = 'translateY(105%)';
|
|
639
|
+
if (sc) { sc.style.transition = 'opacity .22s'; sc.style.opacity = '0'; }
|
|
640
|
+
setTimeout(() => { p.remove(); sc?.remove(); }, 220);
|
|
641
|
+
}
|
|
642
|
+
// Al mòbil, els desplegables (color, etiqueta, assistent) són fulls de baix: a l'amplada, amb nansa, i es tanquen lliscant cap avall.
|
|
643
|
+
const phone = matchMedia('(max-width: 700px)');
|
|
644
|
+
const popGrab = (p) => (p.classList.contains('nt-pop-sheet') ? '<div class="nt-more-grab"></div>' : '');
|
|
628
645
|
function pop(anchor, html) {
|
|
629
646
|
closePop();
|
|
630
647
|
const p = document.createElement('div'); p.className = 'nt-pop'; p.innerHTML = html;
|
|
648
|
+
if (phone.matches) {
|
|
649
|
+
const sc = document.createElement('div'); sc.className = 'nt-more-scrim nt-pop-scrim';
|
|
650
|
+
sc.addEventListener('click', () => closePop(true));
|
|
651
|
+
p.classList.add('nt-pop-sheet'); p.setAttribute('role', 'dialog'); p.insertAdjacentHTML('afterbegin', '<div class="nt-more-grab"></div>');
|
|
652
|
+
ed.card.append(sc, p); ed.pop = p;
|
|
653
|
+
bindMoreDrag(p, sc, () => closePop(true));
|
|
654
|
+
return p;
|
|
655
|
+
}
|
|
631
656
|
ed.card.append(p); ed.pop = p;
|
|
632
657
|
const a = anchor.getBoundingClientRect(), r = ed.card.getBoundingClientRect(), w = p.offsetWidth;
|
|
633
658
|
p.style.maxHeight = `${Math.max(80, a.top - Math.max(r.top, window.visualViewport?.offsetTop || 0) - 16)}px`;
|
|
@@ -636,14 +661,14 @@ export function createNotes(ctx) {
|
|
|
636
661
|
}
|
|
637
662
|
function popColor(b) {
|
|
638
663
|
const p = pop(b, `<div class="nt-colors">${COLORS.map(([k, l]) => `<button type="button" class="nt-dot" data-color="${k}" aria-label="${l}" title="${l}" ${ed.n.color === k ? 'aria-pressed="true"' : ''}></button>`).join('')}</div>`);
|
|
639
|
-
p.addEventListener('click', (e) => { const d = e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
|
|
664
|
+
p.addEventListener('click', (e) => { const d = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
|
|
640
665
|
}
|
|
641
666
|
function popLabel(b) {
|
|
642
667
|
const mine = new Set(ed.n.labels), labs = labelsOf().filter((l) => !mine.has(l));
|
|
643
668
|
const p = pop(b, `<form class="nt-lf"><input type="text" placeholder="Nova etiqueta" maxlength="30" aria-label="Nova etiqueta" autocomplete="off"><button class="btn ink sm" type="submit">Afegeix</button></form>${labs.length ? `<div class="nt-lsug">${labs.map((l) => `<button type="button" class="nt-chip" data-l="${esc(l)}">${esc(l)}</button>`).join('')}</div>` : ''}`);
|
|
644
669
|
const add = (l) => { l = l.trim().toLowerCase(); if (!l || ed.n.labels.includes(l) || ed.n.labels.length >= 12) return; set({ labels: [...ed.n.labels, l] }); drawLabels(); closePop(); };
|
|
645
670
|
$('form', p).onsubmit = (e) => { e.preventDefault(); add($('input', p).value); };
|
|
646
|
-
p.addEventListener('click', (e) => { const c = e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
|
|
671
|
+
p.addEventListener('click', (e) => { const c = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
|
|
647
672
|
setTimeout(() => $('input', p).focus(), 30);
|
|
648
673
|
}
|
|
649
674
|
|
|
@@ -698,19 +723,19 @@ export function createNotes(ctx) {
|
|
|
698
723
|
async function popAi(b) {
|
|
699
724
|
const p = pop(b, '<p class="muted small nt-pad">Mirant quins models hi ha…</p>');
|
|
700
725
|
const list = await loadEngines(); if (ed?.pop !== p) return;
|
|
701
|
-
if (!list.length) { p.innerHTML = '<p class="small nt-pad">No hi ha cap model en aquest ordinador.<br>Instal·la <b>Ollama</b> i un model (<span class="mono">ollama pull qwen3:4b</span>) o <b>Claude Code</b>.</p>'; return; }
|
|
726
|
+
if (!list.length) { p.innerHTML = popGrab(p) + '<p class="small nt-pad">No hi ha cap model en aquest ordinador.<br>Instal·la <b>Ollama</b> i un model (<span class="mono">ollama pull qwen3:4b</span>) o <b>Claude Code</b>.</p>'; return; }
|
|
702
727
|
// Per defecte: el model local més petit de 3 mil milions de paràmetres o més; si no, Claude ràpid; si no, el primer.
|
|
703
728
|
const auto = (list.find((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3) || list.find((e) => e.id === 'claude:haiku') || list[0]).id;
|
|
704
729
|
const saved0 = LSget('nio.notes.engine', ''), saved = list.some((e) => e.id === saved0) ? saved0 : auto, groups = [...new Set(list.map((e) => e.group))];
|
|
705
730
|
const hasText = !!ed.n.text.trim() || !!ed.n.items?.length;
|
|
706
|
-
p.innerHTML = `<label class="nt-pad small"><span class="muted">Model</span><select id="aiEng" class="nt-sel">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === saved ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
|
|
731
|
+
p.innerHTML = popGrab(p) + `<label class="nt-pad small"><span class="muted">Model</span><select id="aiEng" class="nt-sel">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === saved ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
|
|
707
732
|
<div class="nt-acts-list">${ACTIONS.map(([k, l, d]) => `<button type="button" data-ai="${k}" ${hasText ? '' : 'disabled'}><b>${l}</b><span>${d}</span></button>`).join('')}</div>
|
|
708
733
|
<form class="nt-lf" id="aiForm"><input type="text" placeholder="O demana-li qualsevol cosa…" maxlength="300" aria-label="Instrucció pròpia" ${hasText ? '' : 'disabled'}><button class="btn ink sm" type="submit" ${hasText ? '' : 'disabled'}>Envia</button></form>
|
|
709
734
|
<p class="nt-note small muted" id="aiNote"></p>`;
|
|
710
735
|
const eng = $('#aiEng', p), note = $('#aiNote', p);
|
|
711
736
|
const upd = () => { const e = list.find((x) => x.id === eng.value); note.textContent = e?.local ? 'Es queda a l’ordinador: cap dada surt de casa.' : 'Aquest text s’envia al servei del model (Anthropic, OpenAI o núvol d’Ollama).'; };
|
|
712
737
|
eng.onchange = () => { LSset('nio.notes.engine', eng.value); upd(); }; upd();
|
|
713
|
-
p.addEventListener('click', (e) => { const x = e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
|
|
738
|
+
p.addEventListener('click', (e) => { const x = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
|
|
714
739
|
$('#aiForm', p).onsubmit = (e) => { e.preventDefault(); const v = $('input', p).value.trim(); if (v) runAi('custom', eng.value, v); };
|
|
715
740
|
}
|
|
716
741
|
async function runAi(action, engine, prompt) {
|
package/web/app/style.css
CHANGED
|
@@ -141,6 +141,10 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
141
141
|
.chip.usage .ring .rv { stroke: var(--moss); }
|
|
142
142
|
.chip.usage[data-hi="1"] .ring .rv { stroke: var(--red); }
|
|
143
143
|
.chip.usage .uw[data-hi="1"] { color: var(--red); font-weight: 600; }
|
|
144
|
+
/* Al mòbil, l'ús va a la barra de dalt (a la barra de xips quedava fora de la pantalla, per la dreta). */
|
|
145
|
+
.chip.usage.mini { height: 32px; padding: 0 10px 0 8px; font-size: 12px; white-space: nowrap; flex: none; cursor: pointer; }
|
|
146
|
+
#mRight { display: flex; align-items: center; gap: 4px; }
|
|
147
|
+
@media (max-width: 820px) { .mode-bar .chip.usage { display: none; } }
|
|
144
148
|
.chip.beta { height: 20px; padding: 0 7px; font-size: 10px; color: var(--fg-muted); }
|
|
145
149
|
.pulse { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse 1.2s ease-in-out infinite; }
|
|
146
150
|
@keyframes pulse { 50% { opacity: .3; } }
|
package/web/index.html
CHANGED
|
@@ -68,6 +68,8 @@ h1 { font-size: clamp(38px, 6.4vw, 68px); font-weight: 500; line-height: 1.04; l
|
|
|
68
68
|
.install code { background: none; border-radius: 0; flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; padding: 9px 0; font: 500 15.5px ui-monospace, "SF Mono", Menlo, monospace; scrollbar-width: none; }
|
|
69
69
|
.install code::-webkit-scrollbar { display: none; }
|
|
70
70
|
.hero .alt { margin-top: 14px; font-size: 15px; color: var(--fg-muted); }
|
|
71
|
+
.hero .os-sw { border: 0; padding: 0; background: none; font: inherit; color: inherit; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
|
|
72
|
+
.hero .os-sw:hover { color: var(--fg); }
|
|
71
73
|
.copy { flex: none; display: inline-grid; place-items: center; width: 42px; height: 42px; border: 0; border-radius: 11px; background: rgba(255,255,255,.1); color: inherit; cursor: pointer; transition: background-color .2s, transform .12s; }
|
|
72
74
|
.copy:hover { background: rgba(255,255,255,.2); }
|
|
73
75
|
.copy:active { transform: scale(.95); }
|
|
@@ -203,7 +205,7 @@ main { padding-bottom: 8px; }
|
|
|
203
205
|
<h1>Els teus agents, a les teves màquines, des del mòbil.</h1>
|
|
204
206
|
<p class="lead">nio és un comandament per a Claude Code, Codex i altres agents. Treballen als teus ordinadors, amb els teus comptes i les teves claus. Tu els dones feina des del mòbil, els segueixes en directe i aproves el que és arriscat. Personal i gratuït.</p>
|
|
205
207
|
<div class="install"><code id="cmd">curl -fsSL https://www.nio.cat/install.sh | sh</code><button class="copy" type="button" data-copy="curl -fsSL https://www.nio.cat/install.sh | sh" 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>
|
|
206
|
-
<p class="alt"><span>Linux o Mac.</span> <span>Per provar-ho sense instal·lar: <code>npx nio-cat</code></span></p>
|
|
208
|
+
<p class="alt"><span><span id="osTxt">Linux o Mac.</span> <button class="os-sw" id="osSw" type="button">Tens Windows?</button></span> <span>Per provar-ho sense instal·lar: <code>npx nio-cat</code></span></p>
|
|
207
209
|
</div>
|
|
208
210
|
|
|
209
211
|
<section aria-labelledby="com">
|
|
@@ -255,6 +257,20 @@ main { padding-bottom: 8px; }
|
|
|
255
257
|
</footer>
|
|
256
258
|
<script src="/bot.js" defer></script>
|
|
257
259
|
<script>
|
|
260
|
+
// A Windows, l'ordre de PowerShell. Es pot canviar a mà (per instal·lar-lo en un altre ordinador).
|
|
261
|
+
(() => {
|
|
262
|
+
const CMD = { unix: 'curl -fsSL https://www.nio.cat/install.sh | sh', win: 'irm https://www.nio.cat/install.ps1 | iex' };
|
|
263
|
+
const code = document.getElementById('cmd'), copy = code.nextElementSibling, txt = document.getElementById('osTxt'), sw = document.getElementById('osSw');
|
|
264
|
+
const set = (os) => {
|
|
265
|
+
code.textContent = CMD[os]; copy.dataset.copy = CMD[os]; sw.dataset.os = os;
|
|
266
|
+
txt.textContent = os === 'win' ? 'Windows, al PowerShell.' : 'Linux o Mac.';
|
|
267
|
+
sw.textContent = os === 'win' ? 'Tens Mac o Linux?' : 'Tens Windows?';
|
|
268
|
+
};
|
|
269
|
+
sw.onclick = () => set(sw.dataset.os === 'win' ? 'unix' : 'win');
|
|
270
|
+
set(navigator.userAgentData?.platform === 'Windows' || /Windows/.test(navigator.userAgent) ? 'win' : 'unix');
|
|
271
|
+
})();
|
|
272
|
+
</script>
|
|
273
|
+
<script>
|
|
258
274
|
document.addEventListener('click', async (e) => {
|
|
259
275
|
const b = e.target.closest('[data-copy]'); if (!b) return;
|
|
260
276
|
try { await navigator.clipboard.writeText(b.dataset.copy); }
|
package/web/sw.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
|
|
2
|
-
const SHELL = 'nio-shell-
|
|
3
|
-
const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/notes-live.js', '/app/call.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
2
|
+
const SHELL = 'nio-shell-v62';
|
|
3
|
+
const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/notes-live.js', '/app/call.js', '/app/demo.js', '/app/android.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
4
4
|
|
|
5
5
|
self.addEventListener('install', (e) => {
|
|
6
6
|
e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).catch(() => {}));
|