nio-cat 0.4.1 → 0.4.4
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 +5 -4
- package/package.json +1 -1
- package/src/agents/claude.js +13 -3
- package/src/commands.js +51 -0
- package/src/hub.js +41 -6
- package/src/server.js +6 -0
- package/web/app/app.js +186 -19
- package/web/app/style.css +34 -8
- package/web/sw.js +1 -1
package/bin/nio.js
CHANGED
|
@@ -74,12 +74,14 @@ function canOpenBrowser() {
|
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
let panelOpened = false;
|
|
77
78
|
function openDashboard() {
|
|
78
79
|
const local = `http://127.0.0.1:${cfg.port}`;
|
|
79
80
|
// Per defecte s'obre l'app en línia (sempre al dia), ja connectada a aquest ordinador. Amb --local, la còpia que porta nio.
|
|
80
81
|
const online = !flags.local;
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
// &qr=1: el panell mostra el QR per al mòbil tot just obrir-se, a la mateixa pestanya.
|
|
83
|
+
const link = pairLink(online ? flags.app || cfg.app : local, local, cfg.token) + '&qr=1';
|
|
84
|
+
if (canOpenBrowser() && openUrl(link)) { panelOpened = true; console.log(` ${c.dim('Panell:')} ${online ? (flags.app || cfg.app) + '/app' : local} ${c.dim('(obert al navegador; --no-open per evitar-ho' + (online ? '; --local per fer servir la còpia d\'aquest ordinador' : '') + ')')}`); }
|
|
83
85
|
else console.log(` ${c.dim('Panell:')} ${link}`);
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -95,8 +97,7 @@ async function showPair(publicUrl) {
|
|
|
95
97
|
console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')}\n`);
|
|
96
98
|
console.log(terminalQr(qrLink, { margin }));
|
|
97
99
|
} else {
|
|
98
|
-
console.log(`\n ${c.warn('El terminal és massa petit per al QR.')} Fes-lo més gran o obre
|
|
99
|
-
if (canOpenBrowser()) openUrl(file);
|
|
100
|
+
console.log(`\n ${c.warn('El terminal és massa petit per al QR.')} ${panelOpened ? 'El tens al panell del navegador.' : 'Fes-lo més gran, o obre el panell i prem «Continua al mòbil».'}`);
|
|
100
101
|
}
|
|
101
102
|
console.log(`\n ${c.b('Codi:')} ${code}`);
|
|
102
103
|
console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
|
package/package.json
CHANGED
package/src/agents/claude.js
CHANGED
|
@@ -15,10 +15,15 @@ module.exports = {
|
|
|
15
15
|
|
|
16
16
|
start(ctx) {
|
|
17
17
|
const { session, prompt, cfg } = ctx;
|
|
18
|
-
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
18
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
19
19
|
if (session.nativeId) args.push('--resume', session.nativeId);
|
|
20
20
|
if (session.model) args.push('--model', session.model);
|
|
21
21
|
|
|
22
|
+
if (session.mode === 'tot' && process.getuid?.() === 0) {
|
|
23
|
+
ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
|
|
24
|
+
return { kill() {} };
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
const env = {};
|
|
23
28
|
if (session.mode === 'llegir') {
|
|
24
29
|
args.push('--permission-mode', 'plan');
|
|
@@ -65,9 +70,14 @@ function handle(ctx, line) {
|
|
|
65
70
|
if (ev.model) ctx.meta({ model: ev.model });
|
|
66
71
|
}
|
|
67
72
|
break;
|
|
73
|
+
case 'stream_event': {
|
|
74
|
+
const d = ev.event?.type === 'content_block_delta' ? ev.event.delta : null;
|
|
75
|
+
if (d?.type === 'text_delta' && d.text) ctx.stream(d.text);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
68
78
|
case 'assistant':
|
|
69
79
|
for (const b of ev.message?.content || []) {
|
|
70
|
-
if (b.type === 'text' && b.text) ctx.
|
|
80
|
+
if (b.type === 'text' && b.text) ctx.endStream(b.text);
|
|
71
81
|
else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
|
|
72
82
|
}
|
|
73
83
|
break;
|
|
@@ -91,6 +101,6 @@ function handle(ctx, line) {
|
|
|
91
101
|
});
|
|
92
102
|
break;
|
|
93
103
|
default:
|
|
94
|
-
break; //
|
|
104
|
+
break; // rate_limit_event, etc.
|
|
95
105
|
}
|
|
96
106
|
}
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Comandes pròpies de cada agent que es poden escriure amb «/» a la sessió.
|
|
3
|
+
// Claude Code: les teves comandes (~/.claude/commands i .claude/commands del projecte) i les skills.
|
|
4
|
+
// Es passen a l'agent tal qual («/nom arguments»); el mode d'impressió les entén.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
function frontmatter(file) {
|
|
10
|
+
let txt = '';
|
|
11
|
+
try { txt = fs.readFileSync(file, 'utf8').slice(0, 4000); } catch { return {}; }
|
|
12
|
+
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
13
|
+
const out = {};
|
|
14
|
+
if (m) for (const l of m[1].split('\n')) { const kv = l.match(/^([\w-]+):\s*(.*)$/); if (kv) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, ''); }
|
|
15
|
+
if (!out.description) {
|
|
16
|
+
const first = txt.replace(/^---[\s\S]*?---\s*/, '').split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#'));
|
|
17
|
+
if (first) out.description = first;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function listDir(dir) {
|
|
23
|
+
try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function claudeCommands(cwd) {
|
|
27
|
+
const out = new Map();
|
|
28
|
+
const bases = [path.join(os.homedir(), '.claude'), cwd && path.join(cwd, '.claude')].filter(Boolean);
|
|
29
|
+
for (const base of bases) {
|
|
30
|
+
// Comandes: commands/nom.md i commands/espai/nom.md (→ «espai:nom»)
|
|
31
|
+
const cdir = path.join(base, 'commands');
|
|
32
|
+
for (const e of listDir(cdir)) {
|
|
33
|
+
if (e.isFile() && e.name.endsWith('.md')) { const n = e.name.slice(0, -3); out.set(n, { name: n, hint: frontmatter(path.join(cdir, e.name)).description || '', kind: 'comanda' }); }
|
|
34
|
+
else if (e.isDirectory()) for (const f of listDir(path.join(cdir, e.name))) if (f.isFile() && f.name.endsWith('.md')) { const n = `${e.name}:${f.name.slice(0, -3)}`; out.set(n, { name: n, hint: frontmatter(path.join(cdir, e.name, f.name)).description || '', kind: 'comanda' }); }
|
|
35
|
+
}
|
|
36
|
+
// Skills: skills/nom/SKILL.md
|
|
37
|
+
const sdir = path.join(base, 'skills');
|
|
38
|
+
for (const e of listDir(sdir)) {
|
|
39
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
40
|
+
const fm = frontmatter(path.join(sdir, e.name, 'SKILL.md'));
|
|
41
|
+
if (fm.description || fs.existsSync(path.join(sdir, e.name, 'SKILL.md'))) { const n = fm.name || e.name; if (/^[\w:.-]+$/.test(n)) out.set(n, { name: n, hint: fm.description || '', kind: 'skill' }); }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...out.values()].map((c) => ({ ...c, hint: c.hint.slice(0, 110) })).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 150);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function agentCommands(agent, cwd) {
|
|
48
|
+
return agent === 'claude' ? claudeCommands(cwd) : [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { agentCommands };
|
package/src/hub.js
CHANGED
|
@@ -72,6 +72,7 @@ class Hub extends EventEmitter {
|
|
|
72
72
|
updated: s.updated,
|
|
73
73
|
cost: s.cost || 0,
|
|
74
74
|
last: lastText ? clip(lastText.text, 160) : '',
|
|
75
|
+
error: s.state === 'error' ? clip([...s.events].reverse().find((e) => e.kind === 'error')?.text || '', 200) : '',
|
|
75
76
|
pending: [...this.approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length,
|
|
76
77
|
};
|
|
77
78
|
}
|
|
@@ -116,8 +117,20 @@ class Hub extends EventEmitter {
|
|
|
116
117
|
this.persist(s);
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
this.push(s, 'text', { text, open: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Tanca el bloc de text en curs amb el text definitiu (o l'afegeix si no hi havia streaming).
|
|
124
|
+
endStream(s, text) {
|
|
125
|
+
const last = s.events[s.events.length - 1];
|
|
126
|
+
if (last && last.kind === 'text' && last.open) {
|
|
127
|
+
last.text = text;
|
|
128
|
+
delete last.open;
|
|
129
|
+
this.emit('out', { type: 'event', session: s.id, event: last });
|
|
130
|
+
this.touch(s);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.push(s, 'text', { text });
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
// ---------- sessions ----------
|
|
@@ -194,6 +207,8 @@ class Hub extends EventEmitter {
|
|
|
194
207
|
const key = id(16);
|
|
195
208
|
const toolNames = new Map();
|
|
196
209
|
let finished = false;
|
|
210
|
+
let reported = false;
|
|
211
|
+
const tail = [];
|
|
197
212
|
const ctx = {
|
|
198
213
|
session: s,
|
|
199
214
|
prompt: text,
|
|
@@ -212,6 +227,7 @@ class Hub extends EventEmitter {
|
|
|
212
227
|
this.touch(s);
|
|
213
228
|
},
|
|
214
229
|
text: (t) => this.push(s, 'text', { text: String(t) }),
|
|
230
|
+
endStream: (t) => this.endStream(s, String(t)),
|
|
215
231
|
stream: (t) => this.chunk(s, String(t)),
|
|
216
232
|
tool: ({ id: tid, name, input }) => {
|
|
217
233
|
toolNames.set(tid, name);
|
|
@@ -223,10 +239,19 @@ class Hub extends EventEmitter {
|
|
|
223
239
|
if (typeof r.cost === 'number') s.cost = (s.cost || 0) + r.cost;
|
|
224
240
|
this.push(s, 'result', r);
|
|
225
241
|
},
|
|
226
|
-
error: (msg) =>
|
|
242
|
+
error: (msg) => {
|
|
243
|
+
reported = true;
|
|
244
|
+
this.push(s, 'error', { text: clip(typeof msg === 'string' ? msg : JSON.stringify(msg), 1200) });
|
|
245
|
+
},
|
|
246
|
+
fail: (msg) => {
|
|
247
|
+
ctx.error(msg);
|
|
248
|
+
queueMicrotask(() => ctx.done(1));
|
|
249
|
+
},
|
|
227
250
|
log: (line) => {
|
|
228
|
-
// Les línies de stderr
|
|
251
|
+
// Les línies de stderr es guarden (el mòbil les mostra plegades) i les últimes expliquen un error.
|
|
229
252
|
if (/^\s*$/.test(line)) return;
|
|
253
|
+
tail.push(line.trim());
|
|
254
|
+
if (tail.length > 4) tail.shift();
|
|
230
255
|
this.push(s, 'log', { text: clip(line, 600) });
|
|
231
256
|
},
|
|
232
257
|
ask: ({ tool, input }) => this.requestApproval({ session: s.id, tool, input }),
|
|
@@ -238,8 +263,9 @@ class Hub extends EventEmitter {
|
|
|
238
263
|
if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat');
|
|
239
264
|
}
|
|
240
265
|
if (err) this.push(s, 'error', { text: `No s’ha pogut engegar: ${err.message}` });
|
|
241
|
-
else if (code && code !== 0 && code !== 130 && code !== 143) {
|
|
242
|
-
|
|
266
|
+
else if (code && code !== 0 && code !== 130 && code !== 143 && !reported) {
|
|
267
|
+
const why = explain(tail);
|
|
268
|
+
this.push(s, 'error', { text: why || `${agentName(s)} s’ha aturat amb un error (codi ${code})${tail.length ? `:\n${tail.join('\n')}` : ''}` });
|
|
243
269
|
}
|
|
244
270
|
s.state = code === 130 || code === 143 ? 'idle' : code && code !== 0 ? 'error' : 'idle';
|
|
245
271
|
this.push(s, 'status', { state: s.state, code });
|
|
@@ -342,6 +368,15 @@ class Hub extends EventEmitter {
|
|
|
342
368
|
}
|
|
343
369
|
}
|
|
344
370
|
|
|
371
|
+
// Causes conegudes d'una sortida amb error, explicades en català.
|
|
372
|
+
function explain(lines) {
|
|
373
|
+
const t = lines.join('\n');
|
|
374
|
+
if (/cannot be used with root/i.test(t)) return 'Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.';
|
|
375
|
+
if (/not logged in|please run \/login|invalid api key|authentication/i.test(t)) return 'L’agent no té la sessió iniciada en aquest ordinador. Entra-hi per terminal i inicia sessió (per exemple, executant «claude»).';
|
|
376
|
+
if (/rate limit|usage limit|quota|overloaded|429/i.test(t)) return `L’agent ha arribat al límit d’ús o està saturat. Torna-ho a provar d’aquí una estona.\n${lines.slice(-1)[0] || ''}`.trim();
|
|
377
|
+
return '';
|
|
378
|
+
}
|
|
379
|
+
|
|
345
380
|
function agentName(s) {
|
|
346
381
|
return (s && byId[s.agent]?.name) || 'L’agent';
|
|
347
382
|
}
|
package/src/server.js
CHANGED
|
@@ -11,6 +11,7 @@ const { save } = require('./config');
|
|
|
11
11
|
const { httpErr } = require('./hub');
|
|
12
12
|
const github = require('./github');
|
|
13
13
|
const { addServer, knownHosts } = require('./ssh');
|
|
14
|
+
const { agentCommands } = require('./commands');
|
|
14
15
|
const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
|
|
15
16
|
|
|
16
17
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
@@ -269,6 +270,11 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
269
270
|
return { ok: true };
|
|
270
271
|
}
|
|
271
272
|
}
|
|
273
|
+
if ((r = m(/^\/api\/sessions\/([\w-]+)\/commands$/)) && method === 'GET') {
|
|
274
|
+
const s = hub.get(r[1]);
|
|
275
|
+
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
|
276
|
+
return agentCommands(s.agent, s.cwd);
|
|
277
|
+
}
|
|
272
278
|
if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
|
|
273
279
|
const s = hub.get(r[1]);
|
|
274
280
|
if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
|
package/web/app/app.js
CHANGED
|
@@ -31,8 +31,10 @@ const ICON = {
|
|
|
31
31
|
server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
|
|
32
32
|
scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
|
|
33
33
|
check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
34
|
+
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>', speaker: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5zM15.5 9a4 4 0 0 1 0 6M18 6.5a8 8 0 0 1 0 11"/>',
|
|
34
35
|
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"/>'
|
|
35
36
|
};
|
|
37
|
+
const agentBot = (agent, mood = 'idle') => `<span class="abot" data-agent="${esc(agent)}">${bot(mood)}</span>`;
|
|
36
38
|
const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
|
|
37
39
|
const MODES = {
|
|
38
40
|
llegir: { label: 'Només llegir', short: 'Llegir' },
|
|
@@ -247,9 +249,14 @@ function forgetMachine(i) {
|
|
|
247
249
|
}
|
|
248
250
|
function checkPairHash() {
|
|
249
251
|
const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
|
|
252
|
+
const hashAtStart = location.hash;
|
|
250
253
|
history.replaceState(null, '', location.pathname + '#/sessions');
|
|
251
254
|
state.route = { name: 'sessions' };
|
|
252
|
-
|
|
255
|
+
const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
|
|
256
|
+
addMachine(pair); render(); toast('Ordinador connectat');
|
|
257
|
+
// Quan nio obre el panell, el QR per al mòbil surt tot seguit, sense cap pestanya nova.
|
|
258
|
+
if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500);
|
|
259
|
+
return true;
|
|
253
260
|
}
|
|
254
261
|
|
|
255
262
|
/* ---------------------------------------------------------------- navegació */
|
|
@@ -371,7 +378,7 @@ function viewComputers() {
|
|
|
371
378
|
<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>
|
|
372
379
|
</div>
|
|
373
380
|
</div>` : '';
|
|
374
|
-
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
|
|
381
|
+
return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${voicePanel()}${helpPanel()}`;
|
|
375
382
|
}
|
|
376
383
|
|
|
377
384
|
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
@@ -485,7 +492,7 @@ function machinesSheet() {
|
|
|
485
492
|
<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)); });
|
|
486
493
|
}
|
|
487
494
|
function moreSheet() {
|
|
488
|
-
openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
495
|
+
openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<button class="card row" type="button" data-act="voice" style="gap:12px">${svg(ICON.mic)}<b>Veu</b></button><a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
489
496
|
}
|
|
490
497
|
|
|
491
498
|
/* ---------- Sessions */
|
|
@@ -506,9 +513,9 @@ function viewSessions() {
|
|
|
506
513
|
}
|
|
507
514
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
508
515
|
<a class="card session-card" href="#/s/${esc(s.id)}">
|
|
509
|
-
<div class="row between"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
|
|
516
|
+
<div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
|
|
510
517
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
511
|
-
${s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
|
|
518
|
+
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
|
|
512
519
|
<div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
|
|
513
520
|
</a>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
|
|
514
521
|
}
|
|
@@ -573,7 +580,7 @@ let feedEls = new Map(), toolEls = new Map();
|
|
|
573
580
|
function renderSession() {
|
|
574
581
|
const id = state.route.id, view = $('#view');
|
|
575
582
|
view.innerHTML = `<section class="chat">
|
|
576
|
-
<div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
|
|
583
|
+
<div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot" class="abot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
|
|
577
584
|
<div class="mode-bar" id="modeBar"></div>
|
|
578
585
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
579
586
|
<div class="composer" id="composer"></div>
|
|
@@ -593,10 +600,10 @@ function renderSessionHead() {
|
|
|
593
600
|
if (!bar) return;
|
|
594
601
|
$('#sessTitle').textContent = s?.title || 'Sessió nova';
|
|
595
602
|
$('#sessSub').textContent = s ? `${AGENT_NAMES[s.agent] || s.agent} · ${basename(s.cwd)}` : '';
|
|
596
|
-
const hb = $('#headBot'); if (hb) { hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
|
|
603
|
+
const hb = $('#headBot'); if (hb) { hb.dataset.agent = s?.agent || ''; hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
|
|
597
604
|
if (!s) { bar.innerHTML = ''; return; }
|
|
598
605
|
const ag = state.info?.agents?.find((a) => a.id === s.agent);
|
|
599
|
-
bar.innerHTML = `<span class="chip"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
|
|
606
|
+
bar.innerHTML = `<span class="chip"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
|
|
600
607
|
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
|
|
601
608
|
<span class="chip mono">${esc(basename(s.cwd))}</span>
|
|
602
609
|
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
|
|
@@ -640,11 +647,11 @@ function eventNode(e) {
|
|
|
640
647
|
const s = state.sessions.get(state.route.id);
|
|
641
648
|
switch (e.kind) {
|
|
642
649
|
case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
|
|
643
|
-
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}">${md(e.text)}</div>`);
|
|
650
|
+
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && voiceOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
|
|
644
651
|
case 'tool': return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
|
|
645
652
|
case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
|
|
646
653
|
case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
|
|
647
|
-
case 'error': return el(`<div class="ev-error">${esc(e.text)}</div>`);
|
|
654
|
+
case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
|
|
648
655
|
case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
|
|
649
656
|
case 'result': {
|
|
650
657
|
const bits = [];
|
|
@@ -664,10 +671,61 @@ function renderTyping() {
|
|
|
664
671
|
const last = evs ? [...evs.values()].sort((a, b) => b.seq - a.seq).find((x) => x.kind !== 'status' && x.kind !== 'log') : null;
|
|
665
672
|
const streaming = last && last.kind === 'text' && last.open;
|
|
666
673
|
let t = $('.typing', feed);
|
|
667
|
-
if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${
|
|
674
|
+
if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${agentBot(s.agent, 'work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
|
|
668
675
|
else if (t) t.remove();
|
|
669
676
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
670
|
-
|
|
677
|
+
$$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
|
|
678
|
+
}
|
|
679
|
+
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
680
|
+
const NIO_CMDS = [
|
|
681
|
+
{ name: 'mode', hint: 'Canvia els permisos', args: [['llegir', 'Només llegir', 'l'], ['preguntar', 'Demana permís', 'p'], ['tot', 'Via lliure', 't']] },
|
|
682
|
+
{ name: 'model', hint: 'Canvia de model' },
|
|
683
|
+
{ name: 'stop', hint: 'Atura l’agent' },
|
|
684
|
+
{ name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
|
|
685
|
+
{ name: 'git', hint: 'Git i GitHub: commit i PR' },
|
|
686
|
+
{ name: 'nom', hint: 'Posa nom a la sessió', free: true },
|
|
687
|
+
{ name: 'opcions', hint: 'Opcions de la sessió' },
|
|
688
|
+
{ name: 'ajuda', hint: 'Llista de comandes' }
|
|
689
|
+
];
|
|
690
|
+
const cmdCache = new Map();
|
|
691
|
+
async function loadCmds(id) {
|
|
692
|
+
if (cmdCache.has(id) || !machine()) return;
|
|
693
|
+
cmdCache.set(id, []);
|
|
694
|
+
try { cmdCache.set(id, await api(`/api/sessions/${id}/commands`)); } catch { /* sense comandes de l'agent */ }
|
|
695
|
+
}
|
|
696
|
+
const agentCmds = (id) => cmdCache.get(id) || [];
|
|
697
|
+
function pickMode(arg) {
|
|
698
|
+
const a = String(arg || '').trim().toLowerCase(); if (!a) return null;
|
|
699
|
+
const m = NIO_CMDS[0].args.find(([k, , al]) => k === a || al === a || k.startsWith(a) || (a === 'lliure' && k === 'tot'));
|
|
700
|
+
return m ? m[0] : null;
|
|
701
|
+
}
|
|
702
|
+
async function runCmd(s, name, arg) {
|
|
703
|
+
const patch = async (body, msg) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body }); state.sessions.set(s.id, sum); renderSessionHead(); if (msg) toast(msg); } catch (e) { toast(e.message, 4200); } };
|
|
704
|
+
switch (name) {
|
|
705
|
+
case 'mode': { const m = pickMode(arg); if (!arg) return modeSheet(s); if (!m) return toast('Els permisos són: llegir (l), preguntar (p) o tot (t)'); return patch({ mode: m }, `Permisos: ${MODES[m].label}`); }
|
|
706
|
+
case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
|
|
707
|
+
case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
|
|
708
|
+
case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
|
|
709
|
+
case 'git': return gitSheet(s.id);
|
|
710
|
+
case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
|
|
711
|
+
case 'opcions': return sessionOptions(s.id);
|
|
712
|
+
case 'ajuda': return helpCmdsSheet(s);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
function helpCmdsSheet(s) {
|
|
716
|
+
const ac = agentCmds(s.id);
|
|
717
|
+
openSheet(`<h2>Comandes</h2><p class="muted small">Escriu <span class="mono">/</span> al missatge per veure-les.</p><div class="list">${NIO_CMDS.map((c) => `<div class="card"><b class="mono">/${c.name}</b>${c.args ? ' <span class="muted small mono">l · p · t</span>' : ''}<div class="muted small">${esc(c.hint)}</div></div>`).join('')}</div>${ac.length ? `<p class="muted small">De ${esc(AGENT_NAMES[s.agent] || s.agent)}: ${ac.length} comandes i skills. Se li envien a l’agent tal qual.</p>` : ''}<div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
718
|
+
}
|
|
719
|
+
// Llista de suggeriments segons el que hi ha escrit: «/», «/mo», «/mode l».
|
|
720
|
+
function slashItems(s, v) {
|
|
721
|
+
const m = v.match(/^\/([\w:.-]*)$/), a = v.match(/^\/mode\s+(\S*)$/i);
|
|
722
|
+
if (a) { const q = a[1].toLowerCase(); return NIO_CMDS[0].args.filter(([k, , al]) => !q || k.startsWith(q) || al === q).map(([k, h, al]) => ({ fill: `/mode ${k}`, run: ['mode', k], label: `/mode ${k}`, hint: `${h} · ${al}`, tag: 'nio' })); }
|
|
723
|
+
if (!m) return [];
|
|
724
|
+
const q = m[1].toLowerCase();
|
|
725
|
+
const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
|
|
726
|
+
const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
|
|
727
|
+
const all = [...nio, ...ag], starts = all.filter((c) => c.name.toLowerCase().startsWith(q)), has = all.filter((c) => !c.name.toLowerCase().startsWith(q) && c.name.toLowerCase().includes(q));
|
|
728
|
+
return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
|
|
671
729
|
}
|
|
672
730
|
function renderComposer() {
|
|
673
731
|
const box = $('#composer'); if (!box) return;
|
|
@@ -675,17 +733,46 @@ function renderComposer() {
|
|
|
675
733
|
const prev = $('textarea', box)?.value || '';
|
|
676
734
|
if (box.dataset.busy === String(busy) && $('textarea', box)) return;
|
|
677
735
|
box.dataset.busy = String(busy);
|
|
678
|
-
|
|
736
|
+
if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
|
|
737
|
+
box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
679
738
|
const ta = $('#msg'); ta.value = prev;
|
|
680
739
|
const grow = () => autogrow(ta);
|
|
681
|
-
|
|
740
|
+
let items = [], sel = 0;
|
|
741
|
+
const pop = $('#slash');
|
|
742
|
+
function drawSlash() {
|
|
743
|
+
const cur = state.sessions.get(state.route.id); if (!cur || !pop.isConnected) return;
|
|
744
|
+
items = ta.value.includes('\n') ? [] : slashItems(cur, ta.value);
|
|
745
|
+
pop.hidden = !items.length; if (!items.length) return;
|
|
746
|
+
sel = Math.min(sel, items.length - 1);
|
|
747
|
+
pop.innerHTML = items.map((c, i) => `<button type="button" role="option" data-i="${i}" aria-selected="${i === sel}"><span class="n mono">${esc(c.label)}</span><span class="h">${esc(c.hint || '')}</span><span class="t ${c.agent ? 'ag' : ''}">${esc(c.tag)}</span></button>`).join('');
|
|
748
|
+
$('[aria-selected="true"]', pop)?.scrollIntoView({ block: 'nearest' });
|
|
749
|
+
}
|
|
750
|
+
const pick = (i) => {
|
|
751
|
+
const c = items[i]; if (!c) return; const cur = state.sessions.get(state.route.id);
|
|
752
|
+
if (c.run) { ta.value = ''; grow(); drawSlash(); return runCmd(cur, ...c.run); }
|
|
753
|
+
if (c.args || c.free || c.agent) { ta.value = `/${c.name} `; grow(); sel = 0; drawSlash(); ta.focus(); return; }
|
|
754
|
+
ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
|
|
755
|
+
};
|
|
756
|
+
ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
|
|
757
|
+
pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
|
|
682
758
|
const send = async () => {
|
|
683
759
|
const text = ta.value.trim(); if (!text || !s) return;
|
|
684
|
-
|
|
760
|
+
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
761
|
+
ta.value = ''; grow(); drawSlash();
|
|
762
|
+
if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
|
|
685
763
|
try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
|
|
686
764
|
catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
|
|
687
765
|
};
|
|
688
|
-
ta.addEventListener('keydown', (e) => {
|
|
766
|
+
ta.addEventListener('keydown', (e) => {
|
|
767
|
+
if (items.length && !pop.hidden) {
|
|
768
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; return drawSlash(); }
|
|
769
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; return drawSlash(); }
|
|
770
|
+
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing && !/^\/\S+\s/.test(ta.value))) { e.preventDefault(); return pick(sel); }
|
|
771
|
+
if (e.key === 'Escape') { e.preventDefault(); pop.hidden = true; items = []; return; }
|
|
772
|
+
}
|
|
773
|
+
if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); }
|
|
774
|
+
});
|
|
775
|
+
grow(); drawSlash();
|
|
689
776
|
$('#sendBtn').onclick = async () => { if (busy) { try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); } catch (e) { toast(e.message); } } else send(); };
|
|
690
777
|
}
|
|
691
778
|
|
|
@@ -698,8 +785,11 @@ function openSheet(html, onMount, cls) {
|
|
|
698
785
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
699
786
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
700
787
|
|
|
788
|
+
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
789
|
+
const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
|
|
701
790
|
function modeHelp(agentId, mode) {
|
|
702
791
|
const a = state.info?.agents?.find((x) => x.id === agentId);
|
|
792
|
+
if (modeBlocked(agentId, mode)) return 'No disponible: Claude Code no ho permet quan s’executa com a root. Fes servir «Demana permís» o engega nio amb un usuari normal.';
|
|
703
793
|
if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
|
|
704
794
|
if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
|
|
705
795
|
if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
|
|
@@ -782,10 +872,11 @@ async function newSessionSheet(preset = {}) {
|
|
|
782
872
|
},
|
|
783
873
|
() => {
|
|
784
874
|
const ag = agentOf();
|
|
875
|
+
if (modeBlocked(sel.agent, sel.mode)) sel.mode = 'preguntar';
|
|
785
876
|
const modelHtml = sel.agent === 'ollama'
|
|
786
877
|
? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
|
|
787
878
|
: `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
|
|
788
|
-
return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
|
|
879
|
+
return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}" ${modeBlocked(sel.agent, k) ? 'disabled' : ''}><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
|
|
789
880
|
},
|
|
790
881
|
() => `<p class="wiz-q">Què ha de fer?</p>
|
|
791
882
|
<dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
|
|
@@ -850,7 +941,7 @@ async function modelSheet(s) {
|
|
|
850
941
|
});
|
|
851
942
|
}
|
|
852
943
|
function modeSheet(s) {
|
|
853
|
-
openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
|
|
944
|
+
openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" ${modeBlocked(s.agent, k) ? 'disabled' : ''} style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
|
|
854
945
|
$$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
|
|
855
946
|
try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { mode: b.dataset.m } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); }
|
|
856
947
|
catch (e) { toast(e.message); }
|
|
@@ -1140,9 +1231,81 @@ async function sendLocal() {
|
|
|
1140
1231
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1141
1232
|
}
|
|
1142
1233
|
|
|
1234
|
+
/* ---------------------------------------------------------------- veu (ElevenLabs) */
|
|
1235
|
+
// La clau viu només en aquest navegador; les crides van directes a ElevenLabs, sense passar per nio.
|
|
1236
|
+
const VOICE_DEFAULT = { key: '', voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_v3' };
|
|
1237
|
+
const voiceCfg = () => ({ ...VOICE_DEFAULT, ...LS.get('nio.voice', {}) });
|
|
1238
|
+
const voiceOn = () => !!voiceCfg().key;
|
|
1239
|
+
async function elevenlabs(path, init = {}) {
|
|
1240
|
+
const res = await fetch(`https://api.elevenlabs.io${path}`, { ...init, headers: { 'xi-api-key': voiceCfg().key, ...(init.headers || {}) } });
|
|
1241
|
+
if (res.ok) return res;
|
|
1242
|
+
let why = ''; try { const j = await res.json(); why = j.detail?.message || (typeof j.detail === 'string' ? j.detail : ''); } catch { /* sense cos */ }
|
|
1243
|
+
throw new Error(res.status === 401 ? 'La clau d’ElevenLabs no és vàlida.' : res.status === 429 ? 'ElevenLabs diu que has superat el límit. Prova-ho d’aquí una estona.' : `ElevenLabs: ${why || `error ${res.status}`}`);
|
|
1244
|
+
}
|
|
1245
|
+
const speech = { audio: null, btn: null, cache: new Map() };
|
|
1246
|
+
const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0, 4500);
|
|
1247
|
+
function stopSpeech() { speech.audio?.pause(); speech.audio = null; speech.btn?.classList.remove('on', 'wait'); speech.btn = null; }
|
|
1248
|
+
async function speak(btn) {
|
|
1249
|
+
if (speech.btn === btn) return stopSpeech();
|
|
1250
|
+
stopSpeech();
|
|
1251
|
+
const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq)), text = speechText(ev?.text || '');
|
|
1252
|
+
if (!text) return;
|
|
1253
|
+
speech.btn = btn; btn.classList.add('wait');
|
|
1254
|
+
try {
|
|
1255
|
+
const c = voiceCfg(), k = `${c.voice}|${c.model}|${text}`; let url = speech.cache.get(k);
|
|
1256
|
+
if (!url) {
|
|
1257
|
+
const res = await elevenlabs(`/v1/text-to-speech/${encodeURIComponent(c.voice)}?output_format=mp3_44100_64`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: c.model }) });
|
|
1258
|
+
url = URL.createObjectURL(await res.blob()); speech.cache.set(k, url);
|
|
1259
|
+
}
|
|
1260
|
+
if (speech.btn !== btn) return;
|
|
1261
|
+
const a = new Audio(url); speech.audio = a; a.onended = stopSpeech; await a.play();
|
|
1262
|
+
btn.classList.remove('wait'); btn.classList.add('on');
|
|
1263
|
+
} catch (e) { stopSpeech(); toast(e.message, 4500); }
|
|
1264
|
+
}
|
|
1265
|
+
const mic = { rec: null, chunks: [], stream: null };
|
|
1266
|
+
async function toggleMic() {
|
|
1267
|
+
if (!voiceOn()) return voiceSheet();
|
|
1268
|
+
if (mic.rec) return mic.rec.stop();
|
|
1269
|
+
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
|
|
1270
|
+
try { mic.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
|
|
1271
|
+
const rec = new MediaRecorder(mic.stream); mic.rec = rec; mic.chunks = [];
|
|
1272
|
+
rec.ondataavailable = (e) => { if (e.data.size) mic.chunks.push(e.data); };
|
|
1273
|
+
rec.onstop = async () => {
|
|
1274
|
+
mic.stream.getTracks().forEach((t) => t.stop()); mic.rec = null;
|
|
1275
|
+
const blob = new Blob(mic.chunks, { type: rec.mimeType || 'audio/webm' });
|
|
1276
|
+
$('#micBtn')?.classList.remove('rec'); $('#micBtn')?.classList.add('wait');
|
|
1277
|
+
try {
|
|
1278
|
+
if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
|
|
1279
|
+
const fd = new FormData(); fd.append('model_id', 'scribe_v1'); fd.append('file', blob, 'veu.webm');
|
|
1280
|
+
const r = await (await elevenlabs('/v1/speech-to-text', { method: 'POST', body: fd })).json(), ta = $('#msg');
|
|
1281
|
+
if (!r.text?.trim()) toast('No he entès res.');
|
|
1282
|
+
else if (ta) { ta.value = `${ta.value ? `${ta.value} ` : ''}${r.text.trim()}`; ta.dispatchEvent(new Event('input')); ta.focus(); }
|
|
1283
|
+
} catch (e) { toast(e.message, 4500); }
|
|
1284
|
+
$('#micBtn')?.classList.remove('wait');
|
|
1285
|
+
};
|
|
1286
|
+
rec.start(); $('#micBtn')?.classList.add('rec');
|
|
1287
|
+
}
|
|
1288
|
+
function voicePanel() {
|
|
1289
|
+
return `<section class="panel" style="margin-top:28px"><header><h2>Veu</h2>${voiceOn() ? '<span class="chip ok"><i class="dot on"></i>ElevenLabs</span>' : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes de l’agent, amb ElevenLabs.</p><div class="row"><button class="btn ink" data-act="voice" type="button">${voiceOn() ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
|
|
1290
|
+
}
|
|
1291
|
+
function voiceSheet() {
|
|
1292
|
+
const c = voiceCfg();
|
|
1293
|
+
openSheet(`<h2>Veu amb ElevenLabs</h2><p class="muted small">Dicta amb el micròfon i escolta les respostes. La clau es queda només en aquest navegador: les crides van directes a ElevenLabs, nio no les veu.</p>
|
|
1294
|
+
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
1295
|
+
<label class="f"><span>Clau d’API</span><input type="password" id="vKey" value="${esc(c.key)}" placeholder="sk_…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1296
|
+
<label class="f"><span>Identificador de la veu</span><input type="text" id="vVoice" value="${esc(c.voice)}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
1297
|
+
<label class="f"><span>Model</span><select id="vModel"><option value="eleven_v3" ${c.model === 'eleven_v3' ? 'selected' : ''}>v3 (inclou català)</option><option value="eleven_multilingual_v2" ${c.model === 'eleven_multilingual_v2' ? 'selected' : ''}>Multilingual v2</option></select></label>
|
|
1298
|
+
<div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff" ${c.key ? '' : 'disabled'}>Treu la clau</button><button class="btn ink" type="submit">Desa</button></div>
|
|
1299
|
+
</form>`, (sh) => {
|
|
1300
|
+
const save = (v) => { LS.set('nio.voice', v); closeSheet(); if (state.route.name === 'session') renderSession(); else render(); };
|
|
1301
|
+
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const key = $('#vKey', sh).value.trim(); if (!key) return toast('Enganxa la clau d’API.'); save({ key, voice: $('#vVoice', sh).value.trim() || VOICE_DEFAULT.voice, model: $('#vModel', sh).value }); toast('Veu activada'); };
|
|
1302
|
+
$('#vOff', sh).onclick = () => { save({ ...c, key: '' }); toast('Clau eliminada'); };
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1143
1306
|
/* ---------------------------------------------------------------- accions */
|
|
1144
1307
|
document.addEventListener('click', async (e) => {
|
|
1145
|
-
const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
|
|
1308
|
+
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
|
|
1146
1309
|
if (!a) return;
|
|
1147
1310
|
if (a.dataset.copy) {
|
|
1148
1311
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1153,6 +1316,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1153
1316
|
setTimeout(() => { a.innerHTML = html; a.classList.remove('copied'); delete a.dataset.copied; }, 1700);
|
|
1154
1317
|
return;
|
|
1155
1318
|
}
|
|
1319
|
+
if (a.dataset.speak != null) return speak(a);
|
|
1320
|
+
if (a.dataset.mic != null) return toggleMic();
|
|
1156
1321
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1157
1322
|
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1158
1323
|
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
@@ -1185,6 +1350,7 @@ document.addEventListener('click', async (e) => {
|
|
|
1185
1350
|
case 'lm-clear': local.messages = []; return render();
|
|
1186
1351
|
case 'palette': return palette();
|
|
1187
1352
|
case 'tour': return tourStart();
|
|
1353
|
+
case 'voice': return voiceSheet();
|
|
1188
1354
|
}
|
|
1189
1355
|
});
|
|
1190
1356
|
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(); } });
|
|
@@ -1205,6 +1371,7 @@ function commands() {
|
|
|
1205
1371
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1206
1372
|
[...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
|
|
1207
1373
|
c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
|
|
1374
|
+
c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
|
|
1208
1375
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1209
1376
|
return c;
|
|
1210
1377
|
}
|
package/web/app/style.css
CHANGED
|
@@ -104,7 +104,7 @@ kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--bord
|
|
|
104
104
|
.icon-btn { position: relative; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 12px; background: none; color: var(--fg); }
|
|
105
105
|
.icon-btn:hover { background: color-mix(in srgb, var(--bg-2) 80%, transparent); }
|
|
106
106
|
.icon-btn .badge { position: absolute; top: 3px; right: 1px; min-width: 18px; height: 18px; font-size: 10px; }
|
|
107
|
-
input[type="text"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); outline: none; }
|
|
107
|
+
input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--white); outline: none; }
|
|
108
108
|
textarea.field { height: auto; padding: 10px 12px; resize: vertical; line-height: 1.45; }
|
|
109
109
|
input:focus, select:focus, textarea:focus { border-color: var(--fg); }
|
|
110
110
|
select { appearance: none; -webkit-appearance: none; padding-right: 40px; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2369615c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px 16px; }
|
|
@@ -134,8 +134,13 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
134
134
|
@keyframes pulse { 50% { opacity: .3; } }
|
|
135
135
|
.agent-tag { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; }
|
|
136
136
|
.agent-tag::before { content: ""; width: 8px; height: 8px; border-radius: 3px; background: var(--c, var(--fg-muted)); }
|
|
137
|
-
[data-agent="claude"] { --c:
|
|
138
|
-
[data-agent="antigravity"] { --c:
|
|
137
|
+
[data-agent="claude"] { --c: #e8602c; } [data-agent="codex"] { --c: #2b5bd8; } [data-agent="grok"] { --c: var(--fg); }
|
|
138
|
+
[data-agent="antigravity"] { --c: #0f9d8a; } [data-agent="ollama"] { --c: #b08a5a; } [data-agent="demo"] { --c: var(--lemon); } [data-agent="browser"] { --c: #8a5cf6; }
|
|
139
|
+
.agent-tag.nosq::before { display: none; }
|
|
140
|
+
.abot { display: inline-flex; color: var(--c, var(--fg)); }
|
|
141
|
+
.abot .bot { --s: 20px; --bw: .15; }
|
|
142
|
+
.chat-head .abot .bot { --s: 30px; --bw: .14; }
|
|
143
|
+
.typing .abot .bot { --s: 22px; --bw: .15; }
|
|
139
144
|
.empty { padding: 48px 20px; text-align: center; color: var(--fg-muted); display: grid; justify-items: center; gap: 8px; }
|
|
140
145
|
.empty .bot { --s: 56px; --bw: .13; color: var(--fg); margin-bottom: 8px; }
|
|
141
146
|
.empty h2 { font-size: 20px; font-weight: 500; letter-spacing: -.04em; color: var(--fg); }
|
|
@@ -175,6 +180,7 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
175
180
|
|
|
176
181
|
/* ---------- Sessions ---------- */
|
|
177
182
|
.session-card .title { font-size: 16px; font-weight: 500; letter-spacing: -.02em; margin-top: 8px; }
|
|
183
|
+
.session-card .last.err { color: var(--red); }
|
|
178
184
|
.session-card .last { color: var(--fg-muted); font-size: 14px; margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
179
185
|
.session-card .meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; font-size: 12px; color: var(--fg-muted); }
|
|
180
186
|
.fab { position: fixed; right: 24px; bottom: calc(24px + var(--safe-b)); z-index: 15; display: inline-flex; align-items: center; gap: 8px; height: 50px; padding: 0 20px 0 16px; border: 0; border-radius: 16px; background: var(--fg); color: var(--bg); font-weight: 600; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); transition: transform .15s; }
|
|
@@ -183,7 +189,6 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
183
189
|
/* ---------- Xat de sessió ---------- */
|
|
184
190
|
.chat { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
|
185
191
|
.chat-head { display: flex; align-items: center; gap: 12px; padding: 14px 24px; border-bottom: 1px solid var(--border); }
|
|
186
|
-
.chat-head .bot { --s: 30px; --bw: .14; }
|
|
187
192
|
.chat-head h1 { font-size: 17px; font-weight: 600; letter-spacing: -.03em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
188
193
|
.chat-head .sub { font-size: 12px; color: var(--fg-muted); }
|
|
189
194
|
.mode-bar { display: flex; gap: 6px; padding: 10px 24px; overflow-x: auto; scrollbar-width: none; border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); }
|
|
@@ -210,12 +215,11 @@ button.chip:hover { border-color: var(--fg); }
|
|
|
210
215
|
.tool pre { margin: 0 12px 12px; padding: 10px 12px; max-height: 320px; overflow: auto; border-radius: 8px; background: var(--code); font: 12px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
211
216
|
.ev-info, .ev-result { text-align: center; font-size: 12px; color: var(--fg-muted); }
|
|
212
217
|
.ev-result { display: flex; justify-content: center; gap: 6px; }
|
|
213
|
-
.ev-error { padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--red) 35%, transparent); background: color-mix(in srgb, var(--red) 6%, var(--white)); color: var(--red); font-size: 14px; white-space: pre-wrap; }
|
|
218
|
+
.ev-error { display: grid; gap: 2px; padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--red) 35%, transparent); background: color-mix(in srgb, var(--red) 6%, var(--white)); color: var(--red); font-size: 14px; white-space: pre-wrap; }
|
|
214
219
|
.ev-logs { font-size: 12px; color: var(--fg-muted); }
|
|
215
220
|
.ev-logs summary { cursor: pointer; }
|
|
216
221
|
.ev-logs pre { margin-top: 6px; padding: 10px; border-radius: 8px; background: var(--code); font: 11px/1.5 var(--mono); max-height: 220px; overflow: auto; white-space: pre-wrap; }
|
|
217
222
|
.typing { display: flex; align-items: center; gap: 10px; color: var(--fg-muted); font-size: 13px; }
|
|
218
|
-
.typing .bot { --s: 22px; --bw: .15; color: var(--fg); }
|
|
219
223
|
@keyframes rise { from { opacity: 0; transform: translateY(6px); } }
|
|
220
224
|
|
|
221
225
|
.approval { padding: 16px; border-radius: var(--radius); border: 1.5px solid var(--accent); background: var(--white); display: grid; gap: 10px; animation: rise .25s var(--ease); }
|
|
@@ -345,7 +349,7 @@ body.board-mode .view-enter { animation: none; }
|
|
|
345
349
|
body.chat-mode .mbar { position: fixed; left: 0; right: 0; transform: translateY(var(--vvt, 0px)); }
|
|
346
350
|
body.chat-mode .chat { position: fixed; left: 0; right: 0; top: calc(56px + var(--safe-t) + var(--vvt, 0px)); height: calc(var(--vvh, 100dvh) - 56px - var(--safe-t)); }
|
|
347
351
|
body.chat-mode.kb .composer { padding-bottom: 10px; }
|
|
348
|
-
.composer textarea, input[type="text"], input[type="url"], input[type="search"], select, textarea.field { font-size: 16px; }
|
|
352
|
+
.composer textarea, input[type="text"], input[type="password"], input[type="url"], input[type="search"], select, textarea.field { font-size: 16px; }
|
|
349
353
|
.chat-head { display: none; }
|
|
350
354
|
.mode-bar { padding: 10px 16px; }
|
|
351
355
|
.feed { padding: 18px 16px 24px; }
|
|
@@ -449,7 +453,8 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
|
|
|
449
453
|
.burger { display: none; }
|
|
450
454
|
.tabbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 6px 8px calc(6px + var(--safe-b)); background: color-mix(in srgb, var(--bg) 94%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
|
|
451
455
|
.tabbar > * { flex: 1; position: relative; display: grid; justify-items: center; gap: 2px; padding: 6px 0; border: 0; background: none; border-radius: 10px; font-size: 11px; font-weight: 600; color: var(--fg-muted); text-decoration: none; }
|
|
452
|
-
.tabbar [aria-current="page"] { color: var(--
|
|
456
|
+
.tabbar [aria-current="page"] { color: var(--accent); }
|
|
457
|
+
.tabbar [aria-current="page"]::before { content: ""; position: absolute; top: 0; left: 50%; width: 22px; height: 3px; margin-left: -11px; border-radius: 0 0 3px 3px; background: var(--accent); }
|
|
453
458
|
.tabbar .badge { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; line-height: 16px; text-align: center; }
|
|
454
459
|
body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
|
|
455
460
|
body.no-tabs .tabbar, body.kb .tabbar { display: none; }
|
|
@@ -469,3 +474,24 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
|
|
|
469
474
|
.help-body b { color: var(--fg); font-weight: 600; }
|
|
470
475
|
.empty .stepper { text-align: left; max-width: 640px; margin: 16px auto 18px; }
|
|
471
476
|
a.kbd { text-decoration: none; color: inherit; }
|
|
477
|
+
|
|
478
|
+
/* Comandes amb «/» */
|
|
479
|
+
.composer { position: relative; }
|
|
480
|
+
.slash { position: absolute; left: 24px; right: 24px; bottom: 100%; max-width: 760px; margin: 0 auto 6px; max-height: min(320px, 45dvh); overflow-y: auto; overscroll-behavior: contain; display: grid; padding: 6px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 10px 30px rgba(39, 34, 36, .12); z-index: 5; }
|
|
481
|
+
.slash button { display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px; align-items: baseline; text-align: left; padding: 9px 10px; border: 0; border-radius: 9px; background: none; }
|
|
482
|
+
.slash button[aria-selected="true"] { background: var(--bg-2); }
|
|
483
|
+
.slash .n { font-weight: 600; }
|
|
484
|
+
.slash .h { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-muted); font-size: 13px; }
|
|
485
|
+
.slash .t { font-size: 11px; padding: 1px 7px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); }
|
|
486
|
+
.slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
|
|
487
|
+
@media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash .h { grid-column: 1 / -1; grid-row: 2; } }
|
|
488
|
+
/* ---------- Veu ---------- */
|
|
489
|
+
.ev-error b { font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
|
|
490
|
+
.speak { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--fg-muted); font-size: 12px; font-weight: 600; }
|
|
491
|
+
.speak:hover { color: var(--fg); border-color: var(--fg); }
|
|
492
|
+
.speak .ico { width: 15px; height: 15px; }
|
|
493
|
+
.speak.wait { opacity: .6; } .speak.on { color: var(--accent); border-color: var(--accent); }
|
|
494
|
+
.composer .mic { flex: none; display: grid; place-items: center; width: 46px; height: 46px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); color: var(--fg-muted); }
|
|
495
|
+
.composer .mic:hover { color: var(--fg); border-color: var(--fg); }
|
|
496
|
+
.composer .mic.rec { background: var(--red); border-color: var(--red); color: #fff; animation: pulse 1.1s ease-in-out infinite; }
|
|
497
|
+
.composer .mic.wait { opacity: .5; }
|
package/web/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
|
|
2
|
-
const SHELL = 'nio-shell-
|
|
2
|
+
const SHELL = 'nio-shell-v5';
|
|
3
3
|
const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
|
|
4
4
|
|
|
5
5
|
self.addEventListener('install', (e) => {
|