nio-cat 0.4.3 → 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/package.json +1 -1
- package/src/agents/claude.js +13 -3
- package/src/hub.js +41 -6
- package/web/app/app.js +97 -15
- package/web/app/style.css +23 -8
- package/web/sw.js +1 -1
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/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/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' },
|
|
@@ -376,7 +378,7 @@ function viewComputers() {
|
|
|
376
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>
|
|
377
379
|
</div>
|
|
378
380
|
</div>` : '';
|
|
379
|
-
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()}`;
|
|
380
382
|
}
|
|
381
383
|
|
|
382
384
|
/* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
|
|
@@ -490,7 +492,7 @@ function machinesSheet() {
|
|
|
490
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)); });
|
|
491
493
|
}
|
|
492
494
|
function moreSheet() {
|
|
493
|
-
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(); }; });
|
|
494
496
|
}
|
|
495
497
|
|
|
496
498
|
/* ---------- Sessions */
|
|
@@ -511,9 +513,9 @@ function viewSessions() {
|
|
|
511
513
|
}
|
|
512
514
|
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
513
515
|
<a class="card session-card" href="#/s/${esc(s.id)}">
|
|
514
|
-
<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>
|
|
515
517
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
516
|
-
${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>` : ''}
|
|
517
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>
|
|
518
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>`}`;
|
|
519
521
|
}
|
|
@@ -578,7 +580,7 @@ let feedEls = new Map(), toolEls = new Map();
|
|
|
578
580
|
function renderSession() {
|
|
579
581
|
const id = state.route.id, view = $('#view');
|
|
580
582
|
view.innerHTML = `<section class="chat">
|
|
581
|
-
<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>
|
|
582
584
|
<div class="mode-bar" id="modeBar"></div>
|
|
583
585
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
584
586
|
<div class="composer" id="composer"></div>
|
|
@@ -598,10 +600,10 @@ function renderSessionHead() {
|
|
|
598
600
|
if (!bar) return;
|
|
599
601
|
$('#sessTitle').textContent = s?.title || 'Sessió nova';
|
|
600
602
|
$('#sessSub').textContent = s ? `${AGENT_NAMES[s.agent] || s.agent} · ${basename(s.cwd)}` : '';
|
|
601
|
-
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(); }
|
|
602
604
|
if (!s) { bar.innerHTML = ''; return; }
|
|
603
605
|
const ag = state.info?.agents?.find((a) => a.id === s.agent);
|
|
604
|
-
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>
|
|
605
607
|
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
|
|
606
608
|
<span class="chip mono">${esc(basename(s.cwd))}</span>
|
|
607
609
|
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
|
|
@@ -645,11 +647,11 @@ function eventNode(e) {
|
|
|
645
647
|
const s = state.sessions.get(state.route.id);
|
|
646
648
|
switch (e.kind) {
|
|
647
649
|
case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
|
|
648
|
-
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>`);
|
|
649
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>`);
|
|
650
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>`);
|
|
651
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)); }
|
|
652
|
-
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>`);
|
|
653
655
|
case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
|
|
654
656
|
case 'result': {
|
|
655
657
|
const bits = [];
|
|
@@ -669,10 +671,10 @@ function renderTyping() {
|
|
|
669
671
|
const last = evs ? [...evs.values()].sort((a, b) => b.seq - a.seq).find((x) => x.kind !== 'status' && x.kind !== 'log') : null;
|
|
670
672
|
const streaming = last && last.kind === 'text' && last.open;
|
|
671
673
|
let t = $('.typing', feed);
|
|
672
|
-
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(); } }
|
|
673
675
|
else if (t) t.remove();
|
|
674
676
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
675
|
-
|
|
677
|
+
$$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
|
|
676
678
|
}
|
|
677
679
|
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
678
680
|
const NIO_CMDS = [
|
|
@@ -732,7 +734,7 @@ function renderComposer() {
|
|
|
732
734
|
if (box.dataset.busy === String(busy) && $('textarea', box)) return;
|
|
733
735
|
box.dataset.busy = String(busy);
|
|
734
736
|
if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
|
|
735
|
-
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="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
|
|
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>`;
|
|
736
738
|
const ta = $('#msg'); ta.value = prev;
|
|
737
739
|
const grow = () => autogrow(ta);
|
|
738
740
|
let items = [], sel = 0;
|
|
@@ -783,8 +785,11 @@ function openSheet(html, onMount, cls) {
|
|
|
783
785
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
|
|
784
786
|
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
785
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';
|
|
786
790
|
function modeHelp(agentId, mode) {
|
|
787
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.';
|
|
788
793
|
if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
|
|
789
794
|
if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
|
|
790
795
|
if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
|
|
@@ -867,10 +872,11 @@ async function newSessionSheet(preset = {}) {
|
|
|
867
872
|
},
|
|
868
873
|
() => {
|
|
869
874
|
const ag = agentOf();
|
|
875
|
+
if (modeBlocked(sel.agent, sel.mode)) sel.mode = 'preguntar';
|
|
870
876
|
const modelHtml = sel.agent === 'ollama'
|
|
871
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>`
|
|
872
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>`;
|
|
873
|
-
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}`;
|
|
874
880
|
},
|
|
875
881
|
() => `<p class="wiz-q">Què ha de fer?</p>
|
|
876
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>
|
|
@@ -935,7 +941,7 @@ async function modelSheet(s) {
|
|
|
935
941
|
});
|
|
936
942
|
}
|
|
937
943
|
function modeSheet(s) {
|
|
938
|
-
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) => {
|
|
939
945
|
$$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
|
|
940
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(); }
|
|
941
947
|
catch (e) { toast(e.message); }
|
|
@@ -1225,9 +1231,81 @@ async function sendLocal() {
|
|
|
1225
1231
|
local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
|
|
1226
1232
|
}
|
|
1227
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
|
+
|
|
1228
1306
|
/* ---------------------------------------------------------------- accions */
|
|
1229
1307
|
document.addEventListener('click', async (e) => {
|
|
1230
|
-
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]');
|
|
1231
1309
|
if (!a) return;
|
|
1232
1310
|
if (a.dataset.copy) {
|
|
1233
1311
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -1238,6 +1316,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1238
1316
|
setTimeout(() => { a.innerHTML = html; a.classList.remove('copied'); delete a.dataset.copied; }, 1700);
|
|
1239
1317
|
return;
|
|
1240
1318
|
}
|
|
1319
|
+
if (a.dataset.speak != null) return speak(a);
|
|
1320
|
+
if (a.dataset.mic != null) return toggleMic();
|
|
1241
1321
|
if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
|
|
1242
1322
|
if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
|
|
1243
1323
|
if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
|
|
@@ -1270,6 +1350,7 @@ document.addEventListener('click', async (e) => {
|
|
|
1270
1350
|
case 'lm-clear': local.messages = []; return render();
|
|
1271
1351
|
case 'palette': return palette();
|
|
1272
1352
|
case 'tour': return tourStart();
|
|
1353
|
+
case 'voice': return voiceSheet();
|
|
1273
1354
|
}
|
|
1274
1355
|
});
|
|
1275
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(); } });
|
|
@@ -1290,6 +1371,7 @@ function commands() {
|
|
|
1290
1371
|
c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
|
|
1291
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}`) }));
|
|
1292
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 });
|
|
1293
1375
|
c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
|
|
1294
1376
|
return c;
|
|
1295
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; }
|
|
@@ -480,3 +485,13 @@ a.kbd { text-decoration: none; color: inherit; }
|
|
|
480
485
|
.slash .t { font-size: 11px; padding: 1px 7px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); }
|
|
481
486
|
.slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
|
|
482
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) => {
|