nio-cat 0.9.6 → 0.9.9
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/README.md +1 -0
- package/bin/nio.js +6 -2
- package/package.json +1 -1
- package/src/agents/claude.js +6 -1
- package/src/agents/ollama.js +3 -2
- package/src/call.js +246 -0
- package/src/hub.js +23 -0
- package/src/notes.js +293 -0
- package/src/server.js +27 -3
- package/src/terminal.js +147 -0
- package/src/usage.js +79 -0
- package/web/app/app.js +334 -72
- package/web/app/call.css +80 -0
- package/web/app/call.js +435 -0
- package/web/app/demo.js +203 -10
- package/web/app/index.html +4 -2
- package/web/app/notes.css +225 -0
- package/web/app/notes.js +747 -0
- package/web/app/style.css +83 -0
- package/web/index.html +7 -2
- package/web/sw.js +2 -2
package/web/app/app.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// nio · app. Parla directament amb el programa nio del teu ordinador (fetch + flux d'esdeveniments).
|
|
2
2
|
// Sense frameworks ni compilació. També inclou un ordinador de prova que viu dins del navegador.
|
|
3
3
|
|
|
4
|
+
import { createNotes } from './notes.js';
|
|
5
|
+
import { createCall } from './call.js';
|
|
6
|
+
|
|
4
7
|
const $ = (s, e = document) => e.querySelector(s);
|
|
5
8
|
const $$ = (s, e = document) => [...e.querySelectorAll(s)];
|
|
6
9
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
@@ -16,15 +19,17 @@ const CHIP_ICON = {
|
|
|
16
19
|
mode: '<path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z"/>',
|
|
17
20
|
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
18
21
|
model: '<rect x="6" y="6" width="12" height="12" rx="2.5"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/>',
|
|
19
|
-
|
|
22
|
+
usage: '<path d="M4 16a8 8 0 1 1 16 0"/><path d="M12 16l4-5"/>',
|
|
20
23
|
};
|
|
21
24
|
const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
22
25
|
const ICON = {
|
|
23
26
|
activity: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
|
|
24
|
-
jobs: '<rect x="4" y="
|
|
27
|
+
jobs: '<rect x="4" y="5" width="16" height="15" rx="3"/><path d="M8 3v4M16 3v4M4 10h16m-12 5 2 2 5-4"/>',
|
|
25
28
|
screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M9 20h6M12 16v4"/><circle cx="12" cy="10" r="2"/>',
|
|
26
29
|
panic: '<circle cx="12" cy="12" r="9"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7"/>',
|
|
27
|
-
|
|
30
|
+
call: '<path d="M6.6 3.5h2.6l1.4 4-2 1.3a12 12 0 0 0 6.6 6.6l1.3-2 4 1.4v2.6a2 2 0 0 1-2.2 2A17 17 0 0 1 4.6 5.7a2 2 0 0 1 2-2.2z"/>',
|
|
31
|
+
notes: '<path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M9 15l1-4 8-8 3 3-8 8-4 1z"/>',
|
|
32
|
+
memory: '<path d="M12 5a3 3 0 0 0-5-2 4 4 0 0 0-3 6 4 4 0 0 0 0 7 4 4 0 0 0 8 3V5zm0 0a3 3 0 0 1 5-2 4 4 0 0 1 3 6 4 4 0 0 1 0 7 4 4 0 0 1-8 3M7 8l2 2M17 8l-2 2M6 15l3-1M18 15l-3-1"/>',
|
|
28
33
|
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
29
34
|
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
30
35
|
phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
|
|
@@ -70,6 +75,64 @@ const store = {
|
|
|
70
75
|
};
|
|
71
76
|
const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
72
77
|
|
|
78
|
+
/* ---------------------------------------------------------------- cua de tasques
|
|
79
|
+
Les tasques es desen al mòbil i s'envien soles quan l'ordinador es connecta. */
|
|
80
|
+
const mkey = (m) => m?.id || m?.url;
|
|
81
|
+
const queue = {
|
|
82
|
+
get all() { return LS.get('nio.queue.v1', []); },
|
|
83
|
+
set all(v) { LS.set('nio.queue.v1', v); },
|
|
84
|
+
mine() { const k = mkey(machine()); return this.all.filter((t) => t.mid === k); },
|
|
85
|
+
add(t) { const m = machine(); this.all = [...this.all, { id: Math.random().toString(36).slice(2, 10), mid: mkey(m), at: Date.now(), ...t }]; },
|
|
86
|
+
drop(id) { this.all = this.all.filter((t) => t.id !== id); }
|
|
87
|
+
};
|
|
88
|
+
let flushing = false;
|
|
89
|
+
async function flushQueue() {
|
|
90
|
+
if (flushing || !state.connected) return;
|
|
91
|
+
const todo = queue.mine(); if (!todo.length) return;
|
|
92
|
+
flushing = true;
|
|
93
|
+
let sent = 0;
|
|
94
|
+
try {
|
|
95
|
+
for (const t of todo) {
|
|
96
|
+
if (!state.connected) break;
|
|
97
|
+
try {
|
|
98
|
+
const { id, mid, at, ...body } = t;
|
|
99
|
+
const s = await api('/api/sessions', { method: 'POST', body });
|
|
100
|
+
state.sessions.set(s.id, s); queue.drop(id); sent++;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.name === 'TypeError') break; // l'ordinador s'ha apagat un altre cop: es manté a la cua
|
|
103
|
+
queue.drop(t.id); toast(`No s’ha pogut enviar «${t.title || t.text.slice(0, 30)}»: ${e.message}`, 5000); // error de veritat (p. ex. carpeta inexistent)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} finally { flushing = false; }
|
|
107
|
+
if (sent) { toast(sent === 1 ? 'S’ha enviat 1 tasca de la cua' : `S’han enviat ${sent} tasques de la cua`, 4200); render(); }
|
|
108
|
+
}
|
|
109
|
+
function queueSheet() {
|
|
110
|
+
if (!machine()) return go('#/sessions');
|
|
111
|
+
const m = machine(), last = LS.get(`nio.last.${mkey(m)}`, {});
|
|
112
|
+
const agents = Object.entries(AGENT_NAMES).filter(([k]) => !['demo', 'browser', 'ollama'].includes(k));
|
|
113
|
+
const sel = { agent: last.agent && AGENT_NAMES[last.agent] ? last.agent : 'claude', cwd: last.cwd || '', mode: last.mode || 'preguntar' };
|
|
114
|
+
openSheet(`<h2>Tasca per a quan l’ordinador estigui engegat</h2>
|
|
115
|
+
<p class="muted small">Es desa en aquest dispositiu i s’envia sola quan nio es connecti amb ${esc(m.name)}.</p>
|
|
116
|
+
<label class="f"><span>Agent</span><select id="qAgent">${agents.map(([k, n]) => `<option value="${k}" ${k === sel.agent ? 'selected' : ''}>${esc(n)}</option>`).join('')}</select></label>
|
|
117
|
+
<label class="f"><span>Carpeta de l’ordinador</span><input type="text" id="qCwd" class="mono" value="${esc(sel.cwd)}" placeholder="/Users/…/projecte" autocapitalize="off" spellcheck="false"></label>
|
|
118
|
+
<label class="f"><span>Permisos</span><select id="qMode">${Object.entries(MODES).map(([k, x]) => `<option value="${k}" ${k === sel.mode ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</select></label>
|
|
119
|
+
<label class="f"><span>Què ha de fer?</span><textarea class="field" id="qText" rows="5" placeholder="Per exemple: arregla els tests que fallen"></textarea></label>
|
|
120
|
+
<div class="row" style="justify-content:flex-end"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" type="button" id="qSave">Posa-la a la cua</button></div>`, (sh) => {
|
|
121
|
+
sh.querySelector('[data-close]').onclick = closeSheet;
|
|
122
|
+
$('#qSave', sh).onclick = () => {
|
|
123
|
+
const text = $('#qText', sh).value.trim(), cwd = $('#qCwd', sh).value.trim();
|
|
124
|
+
if (!cwd) return toast('Escriu la carpeta de l’ordinador');
|
|
125
|
+
if (!text) return toast('Explica a l’agent què ha de fer');
|
|
126
|
+
queue.add({ agent: $('#qAgent', sh).value, cwd, mode: $('#qMode', sh).value, text, title: text.slice(0, 60) });
|
|
127
|
+
closeSheet(); toast('A la cua. S’enviarà quan l’ordinador estigui connectat.', 4200); render();
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function queueList() {
|
|
132
|
+
const q = queue.mine(); if (!q.length) return '';
|
|
133
|
+
return `<h2 class="sec">A la cua (${q.length})</h2><div class="list">${q.map((t) => `<div class="card"><div class="row between"><span class="agent-tag" data-agent="${esc(t.agent)}">${esc(AGENT_NAMES[t.agent] || t.agent)}</span><button class="btn ghost sm" type="button" data-qdrop="${esc(t.id)}">Treu-la</button></div><div class="title">${esc(t.title)}</div><div class="meta"><span class="mono">${esc(basename(t.cwd))}</span><span>·</span><span>${state.connected ? 'enviant…' : 'espera l’ordinador'}</span><span>·</span><span>${ago(t.at)}</span></div></div>`).join('')}</div>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
73
136
|
/* ---------------------------------------------------------------- utilitats */
|
|
74
137
|
function toast(msg, ms = 2800) {
|
|
75
138
|
const box = $('#toasts'), t = document.createElement('div');
|
|
@@ -236,7 +299,7 @@ async function connect() {
|
|
|
236
299
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
237
300
|
state.connected = true; state.lastError = null; retry = 0;
|
|
238
301
|
rememberIdentity(m.url, state.info.identity);
|
|
239
|
-
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.());
|
|
302
|
+
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.()); flushQueue();
|
|
240
303
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
241
304
|
// Alguns túnels (Cloudflare des d'alguns servidors) no deixen passar el flux en directe: sense «hello» en 5 s, es passa a consultar cada 2 s.
|
|
242
305
|
gotHello = false; let poll = false;
|
|
@@ -302,6 +365,7 @@ function renameMachine(host, id) {
|
|
|
302
365
|
|
|
303
366
|
function handle(msg) {
|
|
304
367
|
const r = state.route;
|
|
368
|
+
callCtl?.onEvent(msg);
|
|
305
369
|
switch (msg.type) {
|
|
306
370
|
case 'device':
|
|
307
371
|
if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
|
|
@@ -309,7 +373,7 @@ function handle(msg) {
|
|
|
309
373
|
case 'hello':
|
|
310
374
|
gotHello = true; state.synced = true;
|
|
311
375
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
312
|
-
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
376
|
+
state.approvals = new Map(msg.approvals.map((a) => [a.id, a])); state.usage = msg.usage || state.usage; notesCtl?.reload();
|
|
313
377
|
state.loaded.clear(); state.events.clear();
|
|
314
378
|
if (r.name === 'session') openSession(r.id); else render();
|
|
315
379
|
break;
|
|
@@ -325,6 +389,7 @@ function handle(msg) {
|
|
|
325
389
|
break;
|
|
326
390
|
case 'event': {
|
|
327
391
|
const evs = state.events.get(msg.session);
|
|
392
|
+
if (msg.event.kind === 'result') api('/api/usage').then((u) => { state.usage = u; if (state.route.name === 'session') renderSessionHead(); }).catch(() => {});
|
|
328
393
|
if (evs) { const was = evs.get(msg.event.seq); evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) { putEvent(msg.event); if (msg.event.kind === 'text' && !msg.event.open && (!was || was.open)) autoSpeak(msg.event); } }
|
|
329
394
|
break;
|
|
330
395
|
}
|
|
@@ -333,6 +398,8 @@ function handle(msg) {
|
|
|
333
398
|
if (e) { e.text += msg.text; if (r.name === 'session' && r.id === msg.session) streamText(msg.session, e); }
|
|
334
399
|
break;
|
|
335
400
|
}
|
|
401
|
+
case 'usage': state.usage = msg.usage; if (r.name === 'session') renderSessionHead(); break;
|
|
402
|
+
case 'note': case 'note-gone': notesCtl?.onEvent(msg); break;
|
|
336
403
|
case 'ollama': onOllama(msg); break;
|
|
337
404
|
case 'job': onJob(msg); break;
|
|
338
405
|
case 'screen': onScreen(msg); break;
|
|
@@ -446,25 +513,27 @@ function checkPairHash() {
|
|
|
446
513
|
/* ---------------------------------------------------------------- navegació */
|
|
447
514
|
const VIEWS = {
|
|
448
515
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
449
|
-
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
450
516
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
517
|
+
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
518
|
+
trucada: { label: 'Trucada', icon: ICON.call, group: 'Feina' },
|
|
519
|
+
notes: { label: 'Notes', icon: ICON.notes, group: 'Organització' },
|
|
520
|
+
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Organització' },
|
|
521
|
+
pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Organització' },
|
|
522
|
+
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors i eines' },
|
|
523
|
+
mobil: { label: 'Xat local', icon: ICON.phone, group: 'Ordinadors i eines' },
|
|
524
|
+
activitat: { label: 'Activitat', icon: ICON.activity, group: 'Ordinadors i eines' },
|
|
525
|
+
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Preferències' },
|
|
526
|
+
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Preferències' }
|
|
458
527
|
};
|
|
459
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
460
|
-
const NEEDS_MACHINE = new Set(['
|
|
528
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', trucada: '#/trucada', notes: '#/notes', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
529
|
+
const NEEDS_MACHINE = new Set(['session']);
|
|
461
530
|
function parseRoute() {
|
|
462
531
|
const h = location.hash.replace(/^#\/?/, '');
|
|
463
532
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
464
533
|
if (h === 'ordinador' || h === 'pantalla') return { name: 'ordinadors' }; // adreces antigues
|
|
465
534
|
if (h === 'models') return { name: 'mobil' };
|
|
466
535
|
if (h === 'mobil') return { name: 'mobil' };
|
|
467
|
-
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
536
|
+
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h.split('?')[0]);
|
|
468
537
|
return { name: k || 'sessions' };
|
|
469
538
|
}
|
|
470
539
|
function go(hash) { if (location.hash === hash) route(); else location.hash = hash; }
|
|
@@ -474,8 +543,8 @@ function route() {
|
|
|
474
543
|
state.route = parseRoute();
|
|
475
544
|
const canon = state.route.name === 'session' ? null : HASH[state.route.name];
|
|
476
545
|
if (canon && location.hash && location.hash !== canon && /^#\/(pantalla|models|ordinador)$/.test(location.hash)) history.replaceState(null, '', canon);
|
|
477
|
-
// Sense cap ordinador,
|
|
478
|
-
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/
|
|
546
|
+
// Sense cap ordinador, cada pàgina es veu igual i explica què hi faltaria; només una sessió concreta no té sentit.
|
|
547
|
+
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/sessions'); state.route = { name: 'sessions' }; }
|
|
479
548
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
480
549
|
}
|
|
481
550
|
|
|
@@ -492,8 +561,8 @@ function renderChrome() {
|
|
|
492
561
|
const m = machine(), sc = $('#sideConn');
|
|
493
562
|
sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(m.name)}</span>${svg(ICON.chev)}` : `<i class="dot"></i><span>Afegeix un ordinador</span>${svg(ICON.plus)}`;
|
|
494
563
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
495
|
-
const more = ['feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
496
|
-
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
564
|
+
const more = ['feines', 'trucada', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
565
|
+
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['notes', ICON.notes, 'Notes', active === 'notes'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
497
566
|
.map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
|
|
498
567
|
.replace('</a><a href="#/ordinadors"', `</a><button type="button" data-act="pantalla-tab">${svg('<path d="M5 3l14 7-6 2.2L10.8 19z"/>')}<span>Pantalla</span></button><a href="#/ordinadors"`)
|
|
499
568
|
+ `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
|
|
@@ -506,6 +575,9 @@ function renderChrome() {
|
|
|
506
575
|
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
507
576
|
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
508
577
|
}
|
|
578
|
+
let notesCtl = null, callCtl = null;
|
|
579
|
+
const getCall = () => (callCtl ||= createCall({ api, esc, toast, bot, machine, isDemo, noMachine, voiceSheet, agentName: (a) => AGENT_NAMES[a] || a, sessions: () => [...state.sessions.values()], approvals: () => [...state.approvals.keys()], refresh: () => {} }));
|
|
580
|
+
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet }));
|
|
509
581
|
function render() {
|
|
510
582
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
511
583
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -513,17 +585,27 @@ function render() {
|
|
|
513
585
|
document.body.classList.toggle('board-mode', board);
|
|
514
586
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
|
|
515
587
|
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
|
|
588
|
+
if (r.name !== 'notes' && lastName === 'notes') notesCtl?.unmount();
|
|
589
|
+
if (r.name !== 'trucada' && lastName === 'trucada') callCtl?.unmount();
|
|
516
590
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
517
591
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
592
|
+
if (r.name === 'notes' && lastName === 'notes' && $('#notesRoot') && machine()) return; // l'editor no es toca quan l'app es repinta
|
|
593
|
+
if (r.name === 'trucada' && lastName === 'trucada' && $('#callRoot')?.dataset.m === String(store.active)) return; // la pàgina de la trucada no es repinta a cada esdeveniment
|
|
518
594
|
const changed = lastName !== r.name || r.name === 'session';
|
|
519
595
|
lastName = r.name;
|
|
520
596
|
if (r.name === 'session') renderSession();
|
|
597
|
+
else if (r.name === 'notes') {
|
|
598
|
+
view.innerHTML = machine() ? '<div class="page" id="notesRoot"></div>' : `<div class="page"><div class="top"><div><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni.</p></div></div>${noMachine('Les notes es desen a l’ordinador. Connecta’n un per escriure’n i gravar-ne.')}</div>`;
|
|
599
|
+
if (machine()) getNotes().mount($('#notesRoot'));
|
|
600
|
+
}
|
|
601
|
+
else if (r.name === 'trucada') { view.innerHTML = `<div class="page" id="callRoot" data-m="${store.active}"></div>`; getCall().mount($('#callRoot')); }
|
|
521
602
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
522
603
|
else {
|
|
523
604
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
524
605
|
view.innerHTML = html && inLocalChat() ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'ajuda' ? viewHelp() : viewSettings()}</div>`;
|
|
525
606
|
if (r.name === 'mobil') bindLocal();
|
|
526
607
|
if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
|
|
608
|
+
if (r.name === 'sessions' && machine() && state.connected) loadTermSessions();
|
|
527
609
|
if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
|
|
528
610
|
if (r.name === 'memoria' && machine()) loadMemory();
|
|
529
611
|
if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
|
|
@@ -538,7 +620,8 @@ function render() {
|
|
|
538
620
|
|
|
539
621
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
540
622
|
const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
|
|
541
|
-
const isPhone = () =>
|
|
623
|
+
const isPhone = () => navigator.userAgentData?.mobile === true || /Android|iPhone|iPod|Mobile/i.test(navigator.userAgent) || (isTouch && matchMedia('(max-width: 1024px)').matches);
|
|
624
|
+
const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
|
|
542
625
|
const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
|
|
543
626
|
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
544
627
|
const reach = new Map();
|
|
@@ -581,6 +664,7 @@ function helpPanel(open) {
|
|
|
581
664
|
|
|
582
665
|
${grp('Treballar amb els agents')}
|
|
583
666
|
${q('Crear una sessió', `<p>A <b>Sessions</b>, prem <b>Nova sessió</b>: tria l’agent, la carpeta (hi ha un explorador de carpetes de l’ordinador) i el nivell de permisos. Escriu què vols i l’agent es posa a treballar. La resposta et va arribant en directe.</p><p>També pots <b>clonar un repositori de GitHub</b> des de Nova sessió: nio fa servir el ${k('gh')} de l’ordinador (cal haver fet ${k('gh auth login')}).</p>`)}
|
|
667
|
+
${q('Continuar una sessió del terminal', `<p>Si tens Claude Code obert al terminal de l’ordinador, surt a dalt de <b>Sessions</b>, a <b>Obertes al terminal</b>, amb el terminal on corre (iTerm, Terminal, VS Code, tmux, SSH…). Toca-la i continua aquí amb tota la conversa. El terminal no es toca: el mòbil en fa una branca pròpia.</p>`)}
|
|
584
668
|
${q('Els tres nivells de permisos', `<ul><li><b>Només llegir</b>: l’agent llegeix i proposa, però no toca cap fitxer.</li><li><b>Demana permís</b>: llegeix sol; abans d’editar o executar alguna cosa, t’ho pregunta (Claude Code).</li><li><b>Via lliure</b>: treballa sense preguntar. Fes-ho servir només en carpetes de confiança.</li></ul><p>Es pot canviar enmig d’una sessió amb ${k('/mode')}.</p>`)}
|
|
585
669
|
${q('Aprovacions', `<p>Quan un agent vol editar un fitxer o executar una ordre, surt una targeta amb el que farà. <b>Aprova</b> o <b>rebutja</b> amb un toc. A <b>Aprovacions</b> tens totes les pendents de totes les sessions juntes.</p>`)}
|
|
586
670
|
${q('Comandes amb «/»', `<p>Al missatge d’una sessió, escriu ${k('/')} i surt la llista amb suggeriments. Les de nio funcionen sempre; les de l’agent (comandes i skills) se li envien tal qual.</p>${rows([
|
|
@@ -597,6 +681,7 @@ function helpPanel(open) {
|
|
|
597
681
|
${q('Git i GitHub', `<p>Dins una sessió, ${k('/git')} (o Opcions → Git i GitHub) mostra els canvis, fa <b>commit</b> (en una branca nova si ets a ${k('main')}) i crea un <b>PR</b>. Cap clau no passa per nio: fa servir el ${k('git')} i el ${k('gh')} de l’ordinador.</p>`)}
|
|
598
682
|
${q('Avisos al mòbil', `<p>A <b>Ajustos → Avisos</b>, prem <b>Activa els avisos</b>. Rebràs una notificació quan un agent et demana permís o acaba la feina. L’avís arriba buit; el detall el demana el mòbil directament a l’ordinador.</p><p>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>`)}
|
|
599
683
|
${q('Veu', `<p>A <b>Ajustos → Veu</b> actives <b>Whisper</b> per <b>dictar</b> els missatges amb el micròfon, gratis i sense que l’àudio surti de l’ordinador. Per <b>escoltar</b> les respostes, hi pots posar una clau d’ElevenLabs o OpenAI, que es queda a l’ordinador.</p>`)}
|
|
684
|
+
${q('Trucada amb en Nio', `<p>A <b>Més → Trucada</b> parles amb en Nio com en una trucada, amb veu en temps real d’ElevenLabs. Et diu quins agents tens, com van les sessions i què espera el teu permís, i li pots demanar que encarregui feina, continuï una sessió o aprovi una acció (abans t’ho repeteix i espera un sí). Si una sessió acaba durant la trucada, t’ho diu.</p><ul><li>Cal una clau d’ElevenLabs a <b>Ajustos → Veu</b>. Es queda a l’ordinador: el mòbil només rep un enllaç que caduca.</li><li>La primera trucada crea l’agent «nio» al teu compte d’ElevenLabs. ElevenLabs cobra per minut.</li><li>Pots interrompre’l parlant, silenciar el micròfon i veure tota la conversa amb <b>Text</b>.</li></ul>`)}
|
|
600
685
|
|
|
601
686
|
${grp('Eines')}
|
|
602
687
|
${q('Pantalla remota', `<p>Mira i controla l’escriptori d’un ordinador des del mòbil. A <b>Ordinadors</b>, baixa fins a la secció <b>Pantalla</b> i prem <b>Obre la pantalla</b>. S’obre a pantalla completa. Cal activar abans <b>Apps de l’escriptori</b> a la mateixa secció.</p><ul><li><b>Mirar</b>: només veus la pantalla, en directe. Amb un dit la mous quan has fet zoom.</li><li><b>Controlar</b>: toc = clic; doble toc = doble clic; mantenir premut = clic dret; dos dits = desplaçar o fer zoom. El botó <b>1 dit</b> canvia entre desplaçar i arrossegar, i el botó <b>2 dits</b> entre desplaçar l’ordinador i fer zoom amb pessics. <b>Teclat</b> obre el teclat del mòbil, i dalt hi tens ${k('⌘')} ${k('⌥')} ${k('⌃')} ${k('⇧')}, ${k('Esc')}, ${k('Tab')} i fletxes.</li><li><b>Zoom amb dos dits</b>: pessiga per apropar i arrossega amb dos dits per moure’t. Al mode Mirar sempre va així, amb doble toc per alternar entre enquadrar i apropar. Al mode Controlar, tria al botó <b>2 dits</b> si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o ${k('Ctrl')} + roda. Els botons <b>− 100% +</b> i <b>1:1</b> fan el mateix i funcionen en tots dos modes.</li><li><b>Desa captura</b>: baixa el fotograma actual com a imatge. <b>Captura</b> (a la secció) en fa una de sola, sense obrir la pantalla.</li><li><b>Si surt negra</b>: al Mac, dona el permís de <b>Gravació de pantalla</b> (i <b>Accessibilitat</b> per controlar) al Terminal on corre <span class="mono">nio-cat</span> i reinicia’l. A Linux, per controlar cal <span class="mono">xdotool</span>.</li></ul>`)}
|
|
@@ -640,24 +725,38 @@ async function disablePush() {
|
|
|
640
725
|
try { const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); await sub?.unsubscribe(); } catch { /* res */ }
|
|
641
726
|
pushOn = false; toast('Avisos desactivats en aquest dispositiu'); render();
|
|
642
727
|
}
|
|
728
|
+
// Sense cap ordinador: el mateix avís a cada pàgina, amb què s'hi podria fer i on connectar-ne un.
|
|
729
|
+
function noMachine(text) {
|
|
730
|
+
return `<div class="panel empty">${bot('sleep')}<h2>No tens cap ordinador connectat</h2><p>${text}</p><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Connecta un ordinador</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div></div>`;
|
|
731
|
+
}
|
|
643
732
|
function viewSettings() {
|
|
644
|
-
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i
|
|
733
|
+
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil, veu i Telegram. Es configuren per a cada ordinador.</p></div></div>';
|
|
734
|
+
if (!machine()) return `${head}<div class="stack-lg" style="max-width:640px">${settingsOff()}</div>`;
|
|
645
735
|
const avisos = machine() ? `<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>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
|
|
646
736
|
if (machine() && pushOn === null) refreshPush();
|
|
647
737
|
if (machine() && tgState === undefined) loadTelegram();
|
|
648
738
|
return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
649
739
|
}
|
|
740
|
+
// Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
|
|
741
|
+
function settingsOff() {
|
|
742
|
+
const sec = (t, d, b) => `<section class="panel off" aria-disabled="true"><header><h2>${t}</h2></header><div class="body stack"><p class="small">${d}</p><div class="row"><button class="btn" type="button" disabled>${b}</button></div></div></section>`;
|
|
743
|
+
return `<div class="card off-note"><div class="grow"><b>No tens cap ordinador connectat</b><div class="muted small">Aquests ajustos es desen a l’ordinador. Connecta’n un per activar-los.</div></div><button class="btn ink sm" data-act="add-computer" type="button">Connecta un ordinador</button></div>
|
|
744
|
+
${sec('Avisos al mòbil', 'Rep un avís quan un agent et demana permís o acaba la feina.', 'Activa els avisos')}
|
|
745
|
+
${sec('Veu', 'Dicta els missatges amb el micròfon, gratis amb Whisper, i escolta les respostes en veu alta.', 'Configura la veu')}
|
|
746
|
+
${sec('Telegram', 'Parla amb els agents des del teu bot de Telegram: instruccions, notes de veu, avisos i aprovacions amb botons.', 'Connecta el bot')}`;
|
|
747
|
+
}
|
|
650
748
|
function viewHelp() {
|
|
651
749
|
return `<div class="top"><div><h1>Ajuda</h1><p>Com es fa cada cosa i què mirar si no connecta.</p></div><button class="btn ink" type="button" data-act="tour">Fes el tutorial</button></div><div style="max-width:720px">${helpPanel()}</div>`;
|
|
652
750
|
}
|
|
653
751
|
function viewComputers() {
|
|
654
752
|
const ms = store.machines, m = machine(), info = state.info;
|
|
655
|
-
const head = `<div class="top"><div><h1>Ordinadors</h1><p>On treballen els agents: el teu ordinador o un servidor. Tria’n un i dona-li feina.</p></div
|
|
753
|
+
const head = `<div class="top"><div><h1>Ordinadors</h1><p>On treballen els agents: el teu ordinador o un servidor. Tria’n un i dona-li feina.</p></div>${ms.length ? `<button class="btn ink" id="addComputer" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button>` : ''}</div>`;
|
|
656
754
|
if (!ms.length) {
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
755
|
+
const phone = isPhone();
|
|
756
|
+
const steps = phone
|
|
757
|
+
? [['Enganxa-la a l’ordinador', 'Al Terminal. Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Surt al navegador de l’ordinador quan acaba. També hi ha un codi.'], ['Dona feina a un agent', 'Tria l’agent i la carpeta, i mira com treballa des d’aquí.']]
|
|
758
|
+
: [['Obre el Terminal', 'Al Mac, ⌘ Espai i escriu «Terminal». A Linux, Ctrl Alt T.'], ['Enganxa l’ordre', 'Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Aquesta web es connecta sola i et mostra el QR del mòbil.']];
|
|
759
|
+
return `${head}<div class="panel empty first-run">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><p>${phone ? 'nio s’instal·la a l’ordinador amb una sola ordre. Copia-la i enganxa-la allà.' : 'Una sola ordre instal·la nio en aquest ordinador i ho deixa tot a punt.'}</p>${cmdBox(INSTALL_CMD)}<ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}${phone ? 'Tinc un codi o un QR' : 'Afegeix ordinador'}</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small">Mac o Linux. Per provar-lo sense instal·lar res, <span class="mono">npx nio-cat</span>.</p></div>`;
|
|
661
760
|
}
|
|
662
761
|
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
663
762
|
<div class="grid2 one">
|
|
@@ -731,8 +830,8 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
731
830
|
const d = await probeLocal(); if (cur !== 'local') return;
|
|
732
831
|
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
733
832
|
if (d && saved >= 0) set('saved', `${intro('Aquest ordinador ja és a la llista', 'El tens connectat com a «' + esc(d.name) + '».')}<div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre sessions</button></div>`);
|
|
734
|
-
else if (d) set('found', `${intro('nio funciona en aquest ordinador', 'Aquest navegador encara no hi té accés.
|
|
735
|
-
else set('none', `${intro('
|
|
833
|
+
else if (d) set('found', `${intro('nio ja funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Escriu això al Terminal i s’obrirà el panell ja connectat:')}${cmdBox('nio pair')}`);
|
|
834
|
+
else set('none', `${intro('Instal·la nio en aquest ordinador', 'Una sola ordre ho deixa tot a punt i engegat, també quan reinicies.')}${todo(['Obre el Terminal. Al Mac, prem ⌘ Espai i escriu «Terminal».', `Enganxa aquesta ordre i prem Retorn:${cmdBox(INSTALL_CMD)}`, 'Quan acabi, aquesta pàgina es connectarà sola.'])}${live('Esperant nio…')}<p class="hint">Mac o Linux. Si ja el tens i està aturat, escriu <span class="mono">nio</span> al Terminal.</p>${store.machines.length ? '' : '<div class="or"><span>o</span></div><button class="btn block" type="button" data-close data-act="demo">Prova una demo, sense instal·lar res</button>'}`);
|
|
736
835
|
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
737
836
|
};
|
|
738
837
|
const runSsh = async (target) => {
|
|
@@ -747,7 +846,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
747
846
|
addMachine({ url: r.url, token: r.token, name: keepName || r.host, kind: 'vps', via: target });
|
|
748
847
|
if (keepName) { const ms = store.machines; ms[store.active].renamed = true; store.machines = ms; }
|
|
749
848
|
shown = 'vps-ok';
|
|
750
|
-
body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a
|
|
849
|
+
body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a>${isPhone() ? '' : '<button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button>'}</div>`;
|
|
751
850
|
} catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; shown = 'vps'; }
|
|
752
851
|
};
|
|
753
852
|
const vps = () => {
|
|
@@ -760,7 +859,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
760
859
|
<ul class="checks"><li>Accés per SSH sense contrasenya (clau SSH)</li><li>Node.js 18 o més nou</li><li>Claude Code instal·lat i amb sessió iniciada</li></ul>`);
|
|
761
860
|
};
|
|
762
861
|
const code = () => {
|
|
763
|
-
set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al
|
|
862
|
+
set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al navegador de l’ordinador quan instal·les nio, o a «Continua al mòbil».')}
|
|
764
863
|
<form class="field-row" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
765
864
|
${canScan ? `<div class="or"><span>o</span></div><button class="btn block" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="hint">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}`);
|
|
766
865
|
};
|
|
@@ -803,7 +902,8 @@ function renameSheet(i) {
|
|
|
803
902
|
});
|
|
804
903
|
}
|
|
805
904
|
async function pantallaTab() {
|
|
806
|
-
if (!machine()
|
|
905
|
+
if (!machine()) return openSheet(`<h2>Pantalla</h2><div class="stack"><p class="small"><b>No tens cap ordinador connectat.</b> Connecta’t a un ordinador per veure’n la pantalla i controlar-lo des del mòbil.</p><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Ara no</button><button class="btn ink" type="button" data-act="add-computer">Connecta un ordinador</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
906
|
+
if (!state.connected) return toast(`No arribo a ${machine().name}. Comprova que nio hi estigui engegat.`, 4500);
|
|
807
907
|
let caps = state.info?.control;
|
|
808
908
|
try { caps = await api('/api/control'); if (state.info) state.info.control = caps; } catch (e) { return toast(e.message, 4500); }
|
|
809
909
|
const d = caps?.escriptori;
|
|
@@ -822,8 +922,9 @@ async function pantallaTab() {
|
|
|
822
922
|
});
|
|
823
923
|
}
|
|
824
924
|
function moreSheet() {
|
|
825
|
-
const item = (k) => `<a class="
|
|
826
|
-
|
|
925
|
+
const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}><span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
|
|
926
|
+
const groups = [...new Set(Object.values(VIEWS).map((v) => v.group))].map((group) => [group, Object.keys(VIEWS).filter((k) => VIEWS[k].group === group)]);
|
|
927
|
+
openSheet(`<h2>Més</h2><nav class="more-sections" aria-label="Totes les seccions">${groups.map(([title, keys]) => `<section><h3>${title}</h3><div>${keys.map(item).join('')}</div></section>`).join('')}</nav>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
827
928
|
}
|
|
828
929
|
|
|
829
930
|
/* ---------- Sessions */
|
|
@@ -834,11 +935,11 @@ function stateChip(s) {
|
|
|
834
935
|
return '';
|
|
835
936
|
}
|
|
836
937
|
let manage = null; // Set d'ids seleccionats quan el mode «Gestiona» és actiu
|
|
837
|
-
const DAY = 864e5
|
|
938
|
+
const DAY = 864e5;
|
|
838
939
|
const manageBar = (list) => {
|
|
839
|
-
const n = manage.size
|
|
940
|
+
const n = manage.size;
|
|
840
941
|
return `<div class="manage-bar"><div class="row"><span class="muted small">Selecciona</span><button class="chip" type="button" data-man="all">Totes</button><button class="chip" type="button" data-man="idle">Acabades</button><button class="chip" type="button" data-man="old">Més de 30 dies</button><button class="chip" type="button" data-man="err">Amb error</button><button class="chip" type="button" data-man="none">Cap</button></div>
|
|
841
|
-
<div class="row between"><span class="muted small">${n ? `${n} seleccionades
|
|
942
|
+
<div class="row between"><span class="muted small">${n ? `${n} seleccionades` : `${list.length} sessions`}</span><span class="row"><button class="btn ghost" type="button" data-act="manage-off">Fet</button><button class="btn danger" type="button" data-act="manage-del" ${n ? '' : 'disabled'}>Esborra${n ? ` (${n})` : ''}</button></span></div></div>`;
|
|
842
943
|
};
|
|
843
944
|
function manageSelect(kind) {
|
|
844
945
|
const list = [...state.sessions.values()], now = Date.now();
|
|
@@ -855,7 +956,48 @@ async function manageDelete() {
|
|
|
855
956
|
toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
|
|
856
957
|
}
|
|
857
958
|
const plain = (t) => `${t}`.replace(/```[\s\S]*?```/g, ' ').replace(/`([^`]*)`/g, '$1').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.|>)\s+/gm, '').replace(/(\*\*|__|\*|_)(?=\S)([^*_]*?\S)\1/g, '$2').replace(/\*\*/g, '').replace(/\s#{1,6}\s/g, ' ').replace(/\s#{1,6}\s/g, ' ').replace(/\s+/g, ' ').trim();
|
|
959
|
+
// Sessions de Claude Code obertes al terminal de l'ordinador: es poden continuar des d'aquí.
|
|
960
|
+
let termSessions = null, termTimer = 0, termAt = 0, termFor = '';
|
|
961
|
+
async function loadTermSessions(force) {
|
|
962
|
+
if (!machine() || !state.connected || state.route.name !== 'sessions') return;
|
|
963
|
+
const key = machine().url;
|
|
964
|
+
if (termFor !== key) { termSessions = null; termFor = key; }
|
|
965
|
+
// render() es crida sovint: només es torna a demanar si fa una estona.
|
|
966
|
+
if (!force && Date.now() - termAt < 4000) return;
|
|
967
|
+
clearTimeout(termTimer); termAt = Date.now();
|
|
968
|
+
try { const r = (await api('/api/claude-sessions')).sessions || []; if (termFor === key) termSessions = r; } catch { termSessions = termSessions || []; }
|
|
969
|
+
const box = $('#termSessions'); if (box && state.route.name === 'sessions') box.innerHTML = termSessionsHtml();
|
|
970
|
+
// Es manté al dia mentre la pàgina és oberta: el terminal canvia sense avisar.
|
|
971
|
+
if (!document.hidden) termTimer = setTimeout(() => loadTermSessions(true), 8000);
|
|
972
|
+
}
|
|
973
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && state.route.name === 'sessions') loadTermSessions(true); });
|
|
974
|
+
const TERM_STATUS = { busy: ['Treballant', 'running'], idle: ['Esperant', ''], waiting: ['Et necessita', 'waiting'], needs_input: ['Et necessita', 'waiting'] };
|
|
975
|
+
function termSessionsHtml() {
|
|
976
|
+
if (!termSessions?.length) return '';
|
|
977
|
+
const taken = new Set([...state.sessions.values()].flatMap((s) => [s.nativeId, s.fromTerminal]).filter(Boolean));
|
|
978
|
+
const fresh = termSessions.filter((s) => !taken.has(s.sessionId));
|
|
979
|
+
if (!fresh.length) return '';
|
|
980
|
+
const where = (t) => [t?.app, t?.tmux && 'tmux', t?.screen && 'screen', t?.ssh && 'SSH'].filter(Boolean).join(' · ');
|
|
981
|
+
return `<section class="term-sessions" aria-label="Al terminal"><h2 class="sec">Obertes al terminal</h2><div class="list">${fresh.map((s) => {
|
|
982
|
+
const [label, cls] = TERM_STATUS[s.status] || [s.status, ''];
|
|
983
|
+
return `<button class="card session-card term-card" type="button" data-act="resume" data-sid="${esc(s.sessionId)}" data-cwd="${esc(s.cwd || '')}" data-sname="${esc(s.title || s.name || '')}">
|
|
984
|
+
<div class="row between"><span class="agent-tag nosq" data-agent="claude">${agentBot('claude', s.status === 'busy' ? 'work' : 'idle')}${esc(where(s.terminal) || 'Terminal')}</span><span class="chip ${cls}">${cls ? '<i class="pulse"></i>' : ''}${esc(label)}</span></div>
|
|
985
|
+
<div class="title">${esc(s.title || s.name || basename(s.cwd || '') || 'Claude Code')}</div>
|
|
986
|
+
${s.ask ? `<div class="last">${esc(plain(s.ask))}</div>` : ''}
|
|
987
|
+
<div class="meta"><span class="mono">${esc(basename(s.cwd || ''))}</span>${s.updated ? `<span>·</span><span>${ago(s.updated)}</span>` : ''}<span class="term-go">Continua aquí →</span></div>
|
|
988
|
+
</button>`;
|
|
989
|
+
}).join('')}</div></section>`;
|
|
990
|
+
}
|
|
991
|
+
function sessionCard(s) {
|
|
992
|
+
return `<${manage ? 'div role="checkbox" tabindex="0" aria-checked="' + manage.has(s.id) + '" data-pick="' + esc(s.id) + '"' : 'a href="#/s/' + esc(s.id) + '"'} class="card session-card ${manage ? 'pick-mode' : ''} ${manage?.has(s.id) ? 'picked' : ''}">
|
|
993
|
+
<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>
|
|
994
|
+
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
995
|
+
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
|
|
996
|
+
<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>
|
|
997
|
+
</${manage ? 'div' : 'a'}>`;
|
|
998
|
+
}
|
|
858
999
|
function viewSessions() {
|
|
1000
|
+
if (!machine()) return `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p></div></div>${noMachine('Connecta’n un per donar feina als agents i seguir-ne les sessions en directe.')}`;
|
|
859
1001
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
860
1002
|
const many = store.machines.length > 1, mach = machine();
|
|
861
1003
|
const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
|
|
@@ -863,16 +1005,14 @@ function viewSessions() {
|
|
|
863
1005
|
const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><span class="row">${list.length && state.connected && !manage ? '<button class="btn" data-act="manage-on" type="button">Gestiona</button>' : ''}${manage ? '' : `<button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button>`}</span></div>${hint}${manage && list.length ? manageBar(list) : ''}`;
|
|
864
1006
|
if (machine() && !state.synced && !state.lastError && !list.length) return `${head}<div class="list" aria-busy="true">${[0, 1, 2].map(() => '<div class="card skel-card"><i class="skel" style="width:34%"></i><i class="skel tall" style="width:78%"></i><i class="skel" style="width:56%"></i></div>').join('')}</div>`;
|
|
865
1007
|
if (!state.connected && !list.length) {
|
|
866
|
-
return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
1008
|
+
return `${head}${queueList()}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost" data-act="new" type="button">Posa una tasca a la cua</button><a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
867
1009
|
}
|
|
868
1010
|
if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
<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>${s.cost ? `<span>·</span><span>${money(s.cost)}</span>` : ''}</div>
|
|
875
|
-
</${manage ? 'div' : '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>`}`;
|
|
1011
|
+
const now = Date.now(), recent = list.filter((s) => now - s.updated < DAY || s.state === 'running' || s.state === 'waiting' || s.pending), old = list.filter((s) => !recent.includes(s));
|
|
1012
|
+
const recentHtml = recent.length ? `<div class="list">${recent.map(sessionCard).join('')}</div>` : '';
|
|
1013
|
+
const oldHtml = old.length ? `<details class="old-sessions"><summary class="muted small">${old.length} sessi${old.length > 1 ? 'ons' : 'o'} anterior${old.length > 1 ? 's' : ''}</summary><div class="list">${old.map(sessionCard).join('')}</div></details>` : '';
|
|
1014
|
+
const termHtml = `<div id="termSessions">${termSessionsHtml()}</div>`;
|
|
1015
|
+
return `${head}${queueList()}${termHtml}${list.length ? `${recentHtml}${oldHtml}` : `<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>`}`;
|
|
876
1016
|
}
|
|
877
1017
|
|
|
878
1018
|
/* ---------- Aprovacions */
|
|
@@ -903,6 +1043,7 @@ async function revokeDevice(id) {
|
|
|
903
1043
|
let activity = null, actFilter = 'tot';
|
|
904
1044
|
const ACT_FILTERS = { tot: 'Tot', approval: 'Aprovacions', tool: 'Eines', job: 'Feines', device: 'Dispositius' };
|
|
905
1045
|
function viewActivity() {
|
|
1046
|
+
if (!machine()) return `<div class="top"><div><h1>Activitat</h1><p>Tot el que fan els agents: què, per què, quan i qui ho ha aprovat.</p></div></div>${noMachine('El registre es guarda a cada ordinador. Connecta’n un per veure què hi han fet els agents.')}`;
|
|
906
1047
|
return `<div class="top"><div><h1>Activitat</h1><p>Tot el que han fet els agents en aquest ordinador: què, per què, quan i qui ho ha aprovat. Es guarda només a l’ordinador.</p></div><button class="btn" data-act="activity-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div>
|
|
907
1048
|
<div class="seg act-filter" role="group">${Object.entries(ACT_FILTERS).map(([k, l]) => `<button type="button" data-filter="${k}" aria-pressed="${k === actFilter}">${l}</button>`).join('')}</div>
|
|
908
1049
|
<div id="actList">${activityHtml()}</div>`;
|
|
@@ -929,7 +1070,8 @@ async function loadActivity() {
|
|
|
929
1070
|
/* ---------- Memòria compartida (fitxers markdown a l'ordinador, per a Claude Code i Codex) */
|
|
930
1071
|
let mem = null;
|
|
931
1072
|
function viewMemory() {
|
|
932
|
-
return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents
|
|
1073
|
+
if (!machine()) return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents: preferències, decisions i context dels projectes.</p></div></div>${noMachine('La memòria són fitxers a l’ordinador. Connecta’n un per veure-la i editar-la.')}`;
|
|
1074
|
+
return `<div class="top"><div><h1>Memòria</h1><p>Preferències, decisions i context que els agents recorden en aquest ordinador. Obre un fitxer per consultar-lo o editar-lo.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
|
|
933
1075
|
<form class="mem-search" id="memSearch" role="search"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria" autocomplete="off"><button class="btn ink" type="submit">Busca</button></form>
|
|
934
1076
|
<div id="memBody">${memHtml()}</div>`;
|
|
935
1077
|
}
|
|
@@ -948,14 +1090,18 @@ async function loadMemory() {
|
|
|
948
1090
|
async function memEdit(p) {
|
|
949
1091
|
let f = { path: p || 'projectes/', content: '' };
|
|
950
1092
|
if (p) { try { f = await api(`/api/memory/f/${encodeURIComponent(p)}`); } catch (e) { return toast(e.message); } }
|
|
951
|
-
openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2>
|
|
952
|
-
${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" autocapitalize="off" spellcheck="false"></label>'}
|
|
1093
|
+
openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2><form class="stack" id="memForm">
|
|
1094
|
+
${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" placeholder="projectes/context.md" required pattern=".+\\.md" title="Escriu un nom acabat en .md, per exemple projectes/context.md" autocapitalize="off" spellcheck="false"></label>'}
|
|
953
1095
|
<label class="f"><span>Contingut</span><textarea class="field mono" id="memText" rows="14" spellcheck="false">${esc(f.content)}</textarea></label>
|
|
954
|
-
<
|
|
955
|
-
|
|
1096
|
+
<p id="memError" class="form-error" role="alert" hidden></p>
|
|
1097
|
+
<div class="row between sheet-foot">${p ? '<button class="btn danger" type="button" id="memDel">Esborra</button>' : '<span></span>'}<button class="btn ink" type="submit" id="memSave">Desa</button></div></form>`, (sh) => {
|
|
1098
|
+
$('#memForm', sh).onsubmit = async (event) => {
|
|
1099
|
+
event.preventDefault();
|
|
956
1100
|
const path = p || $('#memPath', sh).value.trim();
|
|
1101
|
+
const button = $('#memSave', sh), error = $('#memError', sh); button.disabled = true; button.textContent = 'Desant…'; error.hidden = true;
|
|
957
1102
|
try { await api(`/api/memory/f/${encodeURIComponent(path)}`, { method: 'PUT', body: { content: $('#memText', sh).value } }); toast('Desat'); closeSheet(); mem = null; loadMemory(); }
|
|
958
|
-
catch (e) {
|
|
1103
|
+
catch (e) { error.textContent = `${e.message} Pots tornar a prémer «Desa».`; error.hidden = false; }
|
|
1104
|
+
finally { button.disabled = false; button.textContent = 'Desa'; }
|
|
959
1105
|
};
|
|
960
1106
|
if (p) $('#memDel', sh).onclick = async () => {
|
|
961
1107
|
if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
|
|
@@ -993,6 +1139,7 @@ function jobCard(j) {
|
|
|
993
1139
|
</div>`;
|
|
994
1140
|
}
|
|
995
1141
|
function viewJobs() {
|
|
1142
|
+
if (!machine()) return `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia.</p></div></div>${noMachine('Les feines s’executen a l’ordinador. Connecta’n un per programar-ne.')}`;
|
|
996
1143
|
const head = `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia, i encadena-les («quan Claude Code acabi, que Codex ho revisi»). Les fan Claude Code o Codex a l’ordinador.</p></div><button class="btn ink" data-act="job-new" type="button">${svg(ICON.plus)}Nova feina</button></div>`;
|
|
997
1144
|
if (!jobsData) return `${head}<div id="jobsBody"><div class="panel empty">${bot('work')}<h2>Carregant…</h2></div></div>`;
|
|
998
1145
|
return `${head}<div id="jobsBody">${jobsHtml()}</div>`;
|
|
@@ -1098,6 +1245,7 @@ function approvalCard(a, showSession = true) {
|
|
|
1098
1245
|
</div>`;
|
|
1099
1246
|
}
|
|
1100
1247
|
function viewApprovals() {
|
|
1248
|
+
if (!machine()) return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>${noMachine('Quan un agent d’un ordinador teu necessiti permís, la petició t’arribarà aquí.')}`;
|
|
1101
1249
|
const list = [...state.approvals.values()].sort((a, b) => a.created - b.created);
|
|
1102
1250
|
return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>
|
|
1103
1251
|
${machine() && !state.synced && !state.lastError && !list.length ? '<div class="list" aria-busy="true"><div class="card skel-card"><i class="skel" style="width:40%"></i><i class="skel tall" style="width:80%"></i><i class="skel" style="width:60%"></i></div></div>' : list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
|
|
@@ -1147,7 +1295,8 @@ function renderSession() {
|
|
|
1147
1295
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
1148
1296
|
<div class="composer" id="composer"></div>
|
|
1149
1297
|
</section>`;
|
|
1150
|
-
feedEls = new Map(); toolEls = new Map(); typers.clear();
|
|
1298
|
+
feedEls = new Map(); toolEls = new Map(); typers.clear(); pin.node = null; pinNext = false; pendingUser = null;
|
|
1299
|
+
composerRO?.disconnect(); watchComposer($('#composer'));
|
|
1151
1300
|
renderSessionHead();
|
|
1152
1301
|
const feed = $('#feed'), evs = state.events.get(id);
|
|
1153
1302
|
if (!evs) feed.innerHTML = machine() ? '<div class="skel-feed" aria-busy="true"><i class="skel r" style="width:46%"></i><i class="skel" style="width:88%;height:64px"></i><i class="skel" style="width:70%;height:40px"></i></div>' : `<div class="ev-info">${ 'Connecta un ordinador per veure aquesta sessió.'}</div>`;
|
|
@@ -1156,6 +1305,13 @@ function renderSession() {
|
|
|
1156
1305
|
requestAnimationFrame(() => toBottom(feed));
|
|
1157
1306
|
}
|
|
1158
1307
|
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
1308
|
+
// Ús de la subscripció de l'agent (5 h i setmana), com a esfis. Només si l'agent l'ha informat.
|
|
1309
|
+
const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000 - Date.now() > 864e5 ? 'short' : undefined, hour: '2-digit', minute: '2-digit' });
|
|
1310
|
+
function usageChip(agent) {
|
|
1311
|
+
const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
|
|
1312
|
+
const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
|
|
1313
|
+
return `<span class="chip mono usage" title="${esc(`Ús de la subscripció\n${tip}`)}">${svg(CHIP_ICON.usage)}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(w.label)} ${Math.round(w.used * 100)}%</span>`).join('')}</span>`;
|
|
1314
|
+
}
|
|
1159
1315
|
function renderSessionHead() {
|
|
1160
1316
|
renderChrome();
|
|
1161
1317
|
const s = state.sessions.get(state.route.id), bar = $('#modeBar');
|
|
@@ -1169,7 +1325,7 @@ function renderSessionHead() {
|
|
|
1169
1325
|
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${svg(CHIP_ICON.mode)}<span>${esc(MODES[s.mode]?.label || s.mode)}</span></button>`}
|
|
1170
1326
|
<button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${svg(CHIP_ICON.folder)}<span>${esc(basename(s.cwd))}</span></button>
|
|
1171
1327
|
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${svg(CHIP_ICON.model)}<span>${esc(s.model || 'Model per defecte')}</span></button>
|
|
1172
|
-
${
|
|
1328
|
+
${usageChip(s.agent)}
|
|
1173
1329
|
${stateChip(s)}`;
|
|
1174
1330
|
}
|
|
1175
1331
|
// Alçada del camp de text segons el contingut, sense barra de desplaçament fins al límit.
|
|
@@ -1183,6 +1339,45 @@ function autogrow(ta, max = 180) {
|
|
|
1183
1339
|
const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
|
|
1184
1340
|
const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
|
|
1185
1341
|
const toBottom = (f) => { const s = scroller(f); s.scrollTop = s.scrollHeight; };
|
|
1342
|
+
/* En enviar un missatge, puja suaument fins a quedar just sota els xips, i a sota queda l'espai per a la resposta
|
|
1343
|
+
(com més llarga, menys espai de més). Quan la resposta ja no hi cap, la conversa segueix el final com sempre. */
|
|
1344
|
+
const pin = { node: null, until: 0 };
|
|
1345
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
1346
|
+
function feedLines(feed) {
|
|
1347
|
+
const chat = feed.closest('.chat'), bar = $('.mode-bar', chat), comp = $('.composer', chat);
|
|
1348
|
+
return { top: (bar ? bar.getBoundingClientRect().bottom : feed.getBoundingClientRect().top) + 12, bottom: comp ? comp.getBoundingClientRect().top : innerHeight };
|
|
1349
|
+
}
|
|
1350
|
+
// Posició a la pantalla sense comptar les animacions d'entrada (transform), que mourien el càlcul uns quants píxels.
|
|
1351
|
+
function layTop(n) {
|
|
1352
|
+
const op = n.offsetParent || document.body;
|
|
1353
|
+
return op.getBoundingClientRect().top + (op.clientTop || 0) + n.offsetTop - (op === document.body ? 0 : op.scrollTop);
|
|
1354
|
+
}
|
|
1355
|
+
function sizeSpace(feed) {
|
|
1356
|
+
if (!feed) return;
|
|
1357
|
+
if (!pin.node || !feed.contains(pin.node)) { pin.node = null; feed.style.paddingBottom = ''; delete feed.dataset.pad; return; }
|
|
1358
|
+
// El marge de baix original es llegeix un sol cop: treure'l per mesurar faria saltar el scroll a dalt.
|
|
1359
|
+
if (!feed.dataset.pad) feed.dataset.pad = parseFloat(getComputedStyle(feed).paddingBottom) || 24;
|
|
1360
|
+
const base = +feed.dataset.pad, last = feed.lastElementChild;
|
|
1361
|
+
const { top, bottom } = feedLines(feed), span = layTop(last) + last.offsetHeight - layTop(pin.node);
|
|
1362
|
+
feed.style.paddingBottom = `${base + Math.max(0, bottom - top - span - base)}px`;
|
|
1363
|
+
}
|
|
1364
|
+
function pinMessage(feed, node) {
|
|
1365
|
+
pin.node = node; sizeSpace(feed);
|
|
1366
|
+
const delta = layTop(node) - feedLines(feed).top;
|
|
1367
|
+
pin.until = performance.now() + 650;
|
|
1368
|
+
scroller(feed).scrollBy({ top: delta, behavior: reducedMotion.matches ? 'auto' : 'smooth' });
|
|
1369
|
+
}
|
|
1370
|
+
// Després de cada canvi a la conversa: recalcula l'espai i, si eres al final, el segueix (mai a mig de la pujada).
|
|
1371
|
+
function follow(feed, wasNear) {
|
|
1372
|
+
sizeSpace(feed); if (wasNear && performance.now() > pin.until) toBottom(feed);
|
|
1373
|
+
// I un cop més quan el navegador ha acabat de col·locar-ho tot.
|
|
1374
|
+
if (pin.node) requestAnimationFrame(() => { const near = nearBottom(feed); sizeSpace(feed); if (near && performance.now() > pin.until) toBottom(feed); });
|
|
1375
|
+
}
|
|
1376
|
+
let pinNext = false, pendingUser = null;
|
|
1377
|
+
// Si el camp de text canvia d'alçada (dues línies, el teclat…), l'espai es torna a calcular perquè el missatge no es mogui.
|
|
1378
|
+
const composerRO = typeof ResizeObserver !== 'undefined' ? new ResizeObserver((es) => { for (const x of es) { const feed = $('.feed', x.target.closest('.chat')); if (feed && pin.node) { const near = nearBottom(feed); sizeSpace(feed); if (near) toBottom(feed); } } }) : null;
|
|
1379
|
+
const watchComposer = (c) => { if (c) composerRO?.observe(c); };
|
|
1380
|
+
visualViewport?.addEventListener('resize', () => sizeSpace($('#feed') || $('#lfeed')));
|
|
1186
1381
|
// El text de l'agent surt paraula per paraula, tal com arriba, sense refer tota la bombolla a cada tros.
|
|
1187
1382
|
const typers = new Map();
|
|
1188
1383
|
function wordsAfter(text, i, n) {
|
|
@@ -1213,13 +1408,13 @@ const typeTick = (t) => function tick(now) {
|
|
|
1213
1408
|
const stick = nearBottom(feed);
|
|
1214
1409
|
t.shown = wordsAfter(e.text, t.shown, 1 + Math.floor(backlog / 80));
|
|
1215
1410
|
t.node.innerHTML = md(e.text.slice(0, t.shown));
|
|
1216
|
-
|
|
1411
|
+
follow(feed, stick);
|
|
1217
1412
|
}
|
|
1218
1413
|
}
|
|
1219
1414
|
if (!e.open && t.shown >= e.text.length) {
|
|
1220
1415
|
typers.delete(t.seq);
|
|
1221
1416
|
const node = eventNode(e); t.node.replaceWith(node); feedEls.set(e.seq, node);
|
|
1222
|
-
|
|
1417
|
+
const near = nearBottom(feed); renderTyping(); follow(feed, near);
|
|
1223
1418
|
return;
|
|
1224
1419
|
}
|
|
1225
1420
|
t.raf = requestAnimationFrame(tick);
|
|
@@ -1234,6 +1429,7 @@ function putEvent(e, scroll = true) {
|
|
|
1234
1429
|
const st = $('.st', t); st.className = `st ${e.error ? 'err' : 'ok'}`; st.textContent = e.error ? '✕' : '✓';
|
|
1235
1430
|
let pre = $('pre.out', t); if (!pre && e.output) { pre = el('<pre class="out"></pre>'); t.appendChild(pre); }
|
|
1236
1431
|
if (pre) pre.textContent = e.output;
|
|
1432
|
+
sizeSpace(feed);
|
|
1237
1433
|
return;
|
|
1238
1434
|
}
|
|
1239
1435
|
}
|
|
@@ -1243,18 +1439,20 @@ function putEvent(e, scroll = true) {
|
|
|
1243
1439
|
if (last && last.classList.contains('ev-logs')) { const pre = $('pre', last); pre.textContent += `\n${e.text}`; $('summary', last).textContent = `Registre del procés (${pre.textContent.split('\n').length} línies)`; return; }
|
|
1244
1440
|
}
|
|
1245
1441
|
const node = eventNode(e); if (!node) return;
|
|
1442
|
+
if (e.kind === 'user' && pendingUser && pendingUser.text === e.text && !feedEls.get(e.seq)) { pendingUser.node.replaceWith(node); feedEls.set(e.seq, node); pendingUser = null; renderTyping(); return; }
|
|
1246
1443
|
const old = feedEls.get(e.seq);
|
|
1247
1444
|
if (old) old.replaceWith(node); else { const typing = $('.typing', feed); typing ? feed.insertBefore(node, typing) : feed.appendChild(node); }
|
|
1248
1445
|
feedEls.set(e.seq, node);
|
|
1249
1446
|
if (e.kind === 'tool') toolEls.set(e.id, node);
|
|
1250
1447
|
renderTyping();
|
|
1251
|
-
if (scroll &&
|
|
1448
|
+
if (scroll && e.kind === 'user' && pinNext && !old) { pinNext = false; return pinMessage(feed, node); }
|
|
1449
|
+
if (scroll) follow(feed, stick);
|
|
1252
1450
|
}
|
|
1253
1451
|
function eventNode(e) {
|
|
1254
1452
|
const s = state.sessions.get(state.route.id);
|
|
1255
1453
|
switch (e.kind) {
|
|
1256
1454
|
case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
|
|
1257
|
-
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open &&
|
|
1455
|
+
case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && ttsOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
|
|
1258
1456
|
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>`);
|
|
1259
1457
|
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>`);
|
|
1260
1458
|
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)); }
|
|
@@ -1262,7 +1460,6 @@ function eventNode(e) {
|
|
|
1262
1460
|
case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
|
|
1263
1461
|
case 'result': {
|
|
1264
1462
|
const bits = [];
|
|
1265
|
-
if (typeof e.cost === 'number' && e.cost > 0) bits.push(`${e.cost.toFixed(3)} $`);
|
|
1266
1463
|
if (e.ms) bits.push(`${(e.ms / 1000).toFixed(1).replace('.', ',')} s`);
|
|
1267
1464
|
if (e.tokens?.output_tokens || e.tokens?.output) bits.push(`${e.tokens.output_tokens || e.tokens.output} tokens`);
|
|
1268
1465
|
if (e.error) bits.push(String(e.error));
|
|
@@ -1282,6 +1479,7 @@ function renderTyping() {
|
|
|
1282
1479
|
else if (t) t.remove();
|
|
1283
1480
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
1284
1481
|
$$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
|
|
1482
|
+
if (pin.node) sizeSpace(feed);
|
|
1285
1483
|
}
|
|
1286
1484
|
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
1287
1485
|
const NIO_CMDS = [
|
|
@@ -1411,8 +1609,11 @@ function renderComposer() {
|
|
|
1411
1609
|
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
1412
1610
|
ta.value = ''; grow(); drawSlash();
|
|
1413
1611
|
if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
|
|
1612
|
+
// El missatge surt a la pantalla a l'instant; quan arriba l'esdeveniment real, el substitueix.
|
|
1613
|
+
const feed = $('#feed'); pinNext = true;
|
|
1614
|
+
if (feed) { const tmp = el(`<div class="msg-user">${esc(text)}</div>`), typing = $('.typing', feed); typing ? feed.insertBefore(tmp, typing) : feed.appendChild(tmp); pendingUser = { node: tmp, text }; pinNext = false; pinMessage(feed, tmp); }
|
|
1414
1615
|
try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
|
|
1415
|
-
catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
|
|
1616
|
+
catch (e) { pinNext = false; pendingUser?.node.remove(); pendingUser = null; ta.value = text; grow(); toast(e.message, 4200); }
|
|
1416
1617
|
};
|
|
1417
1618
|
ta.addEventListener('keydown', (e) => {
|
|
1418
1619
|
if (items.length && !pop.hidden) {
|
|
@@ -1429,10 +1630,11 @@ function renderComposer() {
|
|
|
1429
1630
|
|
|
1430
1631
|
/* ---------- Fulls */
|
|
1431
1632
|
function openSheet(html, onMount, cls) {
|
|
1432
|
-
const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
|
|
1633
|
+
const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div><button class="icon-btn sheet-close" type="button" data-sheet-close aria-label="Tanca">${svg(ICON.close)}</button>${html}`;
|
|
1433
1634
|
if (!d.open) d.showModal(); raiseToasts();
|
|
1434
1635
|
if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
|
|
1435
1636
|
onMount?.($('#sheetIn'));
|
|
1637
|
+
const title = si.querySelector('h2'); if (title) { title.id = 'sheetTitle'; d.setAttribute('aria-labelledby', title.id); } else { d.removeAttribute('aria-labelledby'); d.setAttribute('aria-label', 'Opcions'); }
|
|
1436
1638
|
}
|
|
1437
1639
|
const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
|
|
1438
1640
|
// A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
|
|
@@ -1443,9 +1645,50 @@ function focusMain() {
|
|
|
1443
1645
|
if (r === 'session') $('#msg')?.focus({ preventScroll: true });
|
|
1444
1646
|
else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
|
|
1445
1647
|
}
|
|
1446
|
-
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
|
|
1648
|
+
$('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet' || e.target.closest('[data-sheet-close]')) closeSheet(); });
|
|
1447
1649
|
$('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
|
|
1448
1650
|
|
|
1651
|
+
// Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
|
|
1652
|
+
// i, si baixa prou o es llença, marxa; si no, torna a pujar. Si el contingut té scroll, primer s'ha d'arribar a dalt.
|
|
1653
|
+
(() => {
|
|
1654
|
+
const d = $('#sheet');
|
|
1655
|
+
let g = null;
|
|
1656
|
+
const scrollerOf = (t) => { for (let el = t; el && el !== d.parentElement; el = el.parentElement) { if (el.scrollHeight > el.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(el).overflowY)) return el; if (el === d) break; } return null; };
|
|
1657
|
+
const reset = () => { d.style.transition = ''; d.style.transform = ''; };
|
|
1658
|
+
d.addEventListener('touchstart', (e) => {
|
|
1659
|
+
g = null;
|
|
1660
|
+
if (!mq.matches || e.touches.length !== 1 || e.target.closest('input, textarea, select, [contenteditable], input[type=range], [data-noswipe]')) return;
|
|
1661
|
+
const t = e.touches[0], sc = scrollerOf(e.target);
|
|
1662
|
+
const head = !!e.target.closest('.grab, .wiz-head, h2');
|
|
1663
|
+
if (sc && sc.scrollTop > 0 && !head) return;
|
|
1664
|
+
g = { y0: t.clientY, x0: t.clientX, dy: 0, lock: null, sc: head ? null : sc, pts: [[e.timeStamp, t.clientY]] };
|
|
1665
|
+
}, { passive: true });
|
|
1666
|
+
d.addEventListener('touchmove', (e) => {
|
|
1667
|
+
if (!g) return;
|
|
1668
|
+
const t = e.touches[0], dy = t.clientY - g.y0, dx = t.clientX - g.x0;
|
|
1669
|
+
if (!g.lock) {
|
|
1670
|
+
if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
|
|
1671
|
+
if (dy > 0 && dy > Math.abs(dx) && !(g.sc && g.sc.scrollTop > 0)) { g.lock = 'y'; d.style.transition = 'none'; }
|
|
1672
|
+
else { g = null; return; }
|
|
1673
|
+
}
|
|
1674
|
+
if (e.cancelable) e.preventDefault();
|
|
1675
|
+
g.dy = Math.max(0, dy); g.pts.push([e.timeStamp, t.clientY]); if (g.pts.length > 5) g.pts.shift();
|
|
1676
|
+
d.style.transform = `translateY(${g.dy}px)`;
|
|
1677
|
+
}, { passive: false });
|
|
1678
|
+
const end = (e) => {
|
|
1679
|
+
const x = g; g = null; if (!x || x.lock !== 'y') return;
|
|
1680
|
+
const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
|
|
1681
|
+
if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
|
|
1682
|
+
if (reduce) { reset(); closeSheet(); return; }
|
|
1683
|
+
d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
|
|
1684
|
+
setTimeout(() => { closeSheet(); requestAnimationFrame(reset); }, 200);
|
|
1685
|
+
} else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
|
|
1686
|
+
};
|
|
1687
|
+
d.addEventListener('touchend', end, { passive: true });
|
|
1688
|
+
d.addEventListener('touchcancel', end, { passive: true });
|
|
1689
|
+
d.addEventListener('close', reset);
|
|
1690
|
+
})();
|
|
1691
|
+
|
|
1449
1692
|
// Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
|
|
1450
1693
|
const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
|
|
1451
1694
|
function modeHelp(agentId, mode) {
|
|
@@ -1460,7 +1703,7 @@ function modeHelp(agentId, mode) {
|
|
|
1460
1703
|
// Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
|
|
1461
1704
|
async function newSessionSheet(preset = {}) {
|
|
1462
1705
|
if (!machine()) return go('#/sessions');
|
|
1463
|
-
if (!state.connected) return
|
|
1706
|
+
if (!state.connected) return queueSheet();
|
|
1464
1707
|
const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
|
|
1465
1708
|
let projects = [];
|
|
1466
1709
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
@@ -1547,7 +1790,10 @@ async function newSessionSheet(preset = {}) {
|
|
|
1547
1790
|
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
1548
1791
|
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
1549
1792
|
try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); LS.set(lastKey, { agent: sel.agent, cwd: sel.cwd, mode: sel.mode, recent: [sel.cwd, ...recents.filter((p) => p !== sel.cwd)].slice(0, 6) }); closeSheet(); go(`#/s/${s.id}`); }
|
|
1550
|
-
catch (e) {
|
|
1793
|
+
catch (e) {
|
|
1794
|
+
if (e.name === 'TypeError') { const { text, ...rest } = sel; queue.add({ ...rest, text: text.trim(), title: text.trim().slice(0, 60) }); closeSheet(); toast('L’ordinador no respon. La tasca és a la cua.', 4200); return render(); }
|
|
1795
|
+
toast(e.message, 4200); btn.disabled = false;
|
|
1796
|
+
}
|
|
1551
1797
|
};
|
|
1552
1798
|
sh.onclick = (e) => {
|
|
1553
1799
|
const t = e.target;
|
|
@@ -1693,7 +1939,7 @@ async function loadOllama(force) {
|
|
|
1693
1939
|
paintModels();
|
|
1694
1940
|
}
|
|
1695
1941
|
function ollamaSection() {
|
|
1696
|
-
if (!machine()) return `<h2 class="sec" style="margin-top:36px">Ollama a l’ordinador</h2><div class="stack-lg" style="max-width:640px"><div class="card"><b>
|
|
1942
|
+
if (!machine()) return `<h2 class="sec" style="margin-top:36px">Ollama a l’ordinador</h2><div class="stack-lg" style="max-width:640px"><div class="card"><b>No tens cap ordinador connectat</b><div class="muted small" style="margin-top:4px">Els models d’Ollama funcionen a l’ordinador i són més grans i capaços. Connecta’n un per veure’ls i descarregar-ne.</div><button class="btn block" style="margin-top:10px" type="button" data-act="add-computer">Connecta un ordinador</button></div></div>`;
|
|
1697
1943
|
return `<div class="row between" style="max-width:640px;margin-top:36px"><h2 class="sec" style="margin:0">Ollama a ${esc(machine().name)}</h2><button class="icon-btn" data-act="ollama-refresh" type="button" aria-label="Actualitza">${svg(ICON.refresh)}</button></div><div id="modelsBody" style="max-width:640px;margin-top:12px">${state.ollama ? '' : `<div class="card"><b>Consultant Ollama…</b></div>`}</div>`;
|
|
1698
1944
|
}
|
|
1699
1945
|
function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
|
|
@@ -1853,7 +2099,7 @@ const inLocalChat = () => !!(local.engine || local.ollama);
|
|
|
1853
2099
|
function bindLocal() {
|
|
1854
2100
|
const feed = $('#lfeed'); if (!feed) return;
|
|
1855
2101
|
for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
|
|
1856
|
-
toBottom(feed);
|
|
2102
|
+
pin.node = null; toBottom(feed); composerRO?.disconnect(); watchComposer($('.composer', feed.closest('.chat')));
|
|
1857
2103
|
const ta = $('#lmsg');
|
|
1858
2104
|
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1859
2105
|
ta.addEventListener('input', () => autogrow(ta));
|
|
@@ -1903,14 +2149,14 @@ async function sendLocal() {
|
|
|
1903
2149
|
ta.value = ''; autogrow(ta);
|
|
1904
2150
|
const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
|
|
1905
2151
|
local.messages.push({ role: 'user', content: text });
|
|
1906
|
-
|
|
1907
|
-
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out);
|
|
2152
|
+
const mine = el(`<div class="msg-user">${esc(text)}</div>`); feed.appendChild(mine);
|
|
2153
|
+
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); pinMessage(feed, mine);
|
|
1908
2154
|
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1909
2155
|
try {
|
|
1910
2156
|
if (local.ollama) {
|
|
1911
2157
|
const r = await api('/api/ollama/chat', { method: 'POST', body: { model: local.ollama, messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-12)] } });
|
|
1912
2158
|
if (!local.stopped) acc = r.content || '';
|
|
1913
|
-
out.innerHTML = md(stripThink(acc));
|
|
2159
|
+
const near = nearBottom(feed); out.innerHTML = md(stripThink(acc)); follow(feed, near);
|
|
1914
2160
|
} else {
|
|
1915
2161
|
const chunks = await local.engine.chat.completions.create({
|
|
1916
2162
|
messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
|
|
@@ -1918,7 +2164,7 @@ async function sendLocal() {
|
|
|
1918
2164
|
extra_body: { enable_thinking: false }
|
|
1919
2165
|
});
|
|
1920
2166
|
for await (const c of chunks) {
|
|
1921
|
-
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc));
|
|
2167
|
+
acc += c.choices[0]?.delta?.content || ''; const near = nearBottom(feed); out.innerHTML = md(stripThink(acc)); follow(feed, near);
|
|
1922
2168
|
if (local.stopped) break;
|
|
1923
2169
|
}
|
|
1924
2170
|
}
|
|
@@ -2318,6 +2564,8 @@ document.addEventListener('click', async (e) => {
|
|
|
2318
2564
|
// Les claus (ElevenLabs o OpenAI) viuen a l'ordinador. L'àudio fa: mòbil → ordinador → proveïdor.
|
|
2319
2565
|
let nodeVoice = null; // estat de la veu de l'ordinador actiu (sense claus)
|
|
2320
2566
|
const voiceOn = () => !!nodeVoice?.stt;
|
|
2567
|
+
// Escoltar necessita una clau d'ElevenLabs o OpenAI: amb Whisper sol no hi ha veu per parlar.
|
|
2568
|
+
const ttsOn = () => !!nodeVoice?.tts;
|
|
2321
2569
|
async function loadVoice() { try { nodeVoice = await api('/api/voice'); } catch { nodeVoice = null; } }
|
|
2322
2570
|
async function nodeAudio(path, opts) {
|
|
2323
2571
|
const m = machine(); if (!m) throw new Error('No hi ha cap ordinador connectat');
|
|
@@ -2384,13 +2632,15 @@ async function voiceSheet() {
|
|
|
2384
2632
|
openSheet(`<h2>Veu</h2><p class="muted small">Per dictar n’hi ha prou amb Whisper, gratis i a l’ordinador «${esc(machine().name)}». Les claus són opcionals: serveixen per escoltar les respostes en veu alta. Es desen a l’ordinador i no tornen mai al mòbil.</p>
|
|
2385
2633
|
<div id="whisperBox" style="margin-top:14px">${whisperCard(v.whisper)}</div>
|
|
2386
2634
|
<form class="stack" id="voiceForm" style="margin-top:14px">
|
|
2635
|
+
<details class="voice-extras"><summary>Extres opcionals · ElevenLabs i OpenAI</summary><div class="stack"><p class="muted small">Afegeix una clau per fer servir aquests serveis. Whisper funciona sense cap clau.</p>
|
|
2387
2636
|
<label class="f"><span>Clau d’ElevenLabs ${v.providers.elevenlabs.key ? `<span class="muted">(desada: ${esc(v.providers.elevenlabs.key)})</span>` : ''}</span><input type="password" id="vEl" placeholder="${v.providers.elevenlabs.key ? 'Deixa-ho buit per mantenir-la' : 'sk_…'}" value="${!v.providers.elevenlabs.key && old.key ? esc(old.key) : ''}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
2388
2637
|
<label class="f"><span>Clau d’OpenAI ${v.providers.openai.key ? `<span class="muted">(desada: ${esc(v.providers.openai.key)})</span>` : ''}</span><input type="password" id="vOa" placeholder="${v.providers.openai.key ? 'Deixa-ho buit per mantenir-la' : 'sk-…'}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
|
|
2389
2638
|
<div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option>${v.whisper ? `<option value="whisper" ${v.stt === 'whisper' ? 'selected' : ''}>Whisper (gratis)</option>` : ''}<option value="elevenlabs" ${v.stt === 'elevenlabs' ? 'selected' : ''}>ElevenLabs Scribe</option><option value="openai" ${v.stt === 'openai' ? 'selected' : ''}>OpenAI</option></select></label>
|
|
2390
2639
|
<label class="f"><span>Per parlar</span><select id="vTts"><option value="">Automàtic</option><option value="elevenlabs" ${v.tts === 'elevenlabs' ? 'selected' : ''}>ElevenLabs</option><option value="openai" ${v.tts === 'openai' ? 'selected' : ''}>OpenAI</option></select></label></div>
|
|
2391
2640
|
<label class="row check"><input type="checkbox" id="vSpeak" ${v.speak ? 'checked' : ''}> <span>Llegeix en veu alta les respostes de la sessió oberta</span></label>
|
|
2392
2641
|
${old.key ? '<p class="muted small">Tenies una clau d’ElevenLabs desada en aquest navegador: la passo a l’ordinador i l’esborro d’aquí.</p>' : ''}
|
|
2393
|
-
<
|
|
2642
|
+
<button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
|
|
2643
|
+
<div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
|
|
2394
2644
|
</form>`, (sh) => {
|
|
2395
2645
|
const put = async (body, msg) => { try { nodeVoice = await api('/api/voice', { method: 'PUT', body }); try { localStorage.removeItem('nio.voice'); } catch { /* res */ } closeSheet(); toast(msg); if (state.route.name === 'session') renderSession(); else render(); } catch (e) { toast(e.message, 4500); } };
|
|
2396
2646
|
$('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const keys = {}; const el = $('#vEl', sh).value.trim(), oa = $('#vOa', sh).value.trim(); if (el) keys.elevenlabs = el; if (oa) keys.openai = oa; put({ keys, stt: $('#vStt', sh).value || null, tts: $('#vTts', sh).value || null, speak: $('#vSpeak', sh).checked }, 'Veu desada a l’ordinador'); };
|
|
@@ -2445,7 +2695,7 @@ async function hydrateShots(root = document) {
|
|
|
2445
2695
|
}
|
|
2446
2696
|
}
|
|
2447
2697
|
document.addEventListener('click', async (e) => {
|
|
2448
|
-
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm], [data-man], [data-pick]');
|
|
2698
|
+
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm], [data-man], [data-pick], [data-qdrop]');
|
|
2449
2699
|
if (!a) return;
|
|
2450
2700
|
if (a.dataset.copy) {
|
|
2451
2701
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -2466,8 +2716,20 @@ document.addEventListener('click', async (e) => {
|
|
|
2466
2716
|
if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
|
|
2467
2717
|
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
2468
2718
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
2719
|
+
if (a.dataset.qdrop) { queue.drop(a.dataset.qdrop); return render(); }
|
|
2469
2720
|
switch (a.dataset.act) {
|
|
2470
2721
|
case 'new': return newSessionSheet();
|
|
2722
|
+
case 'resume': {
|
|
2723
|
+
const btn = a; btn.disabled = true;
|
|
2724
|
+
const sid = btn.dataset.sid, cwd = btn.dataset.cwd, sname = btn.dataset.sname;
|
|
2725
|
+
try {
|
|
2726
|
+
const title = sname || basename(cwd) || 'Terminal';
|
|
2727
|
+
const ns = await api('/api/sessions', { method: 'POST', body: { agent: 'claude', cwd: cwd || '', mode: 'preguntar', resumeId: sid, title } });
|
|
2728
|
+
state.sessions.set(ns.id, ns); termSessions = termSessions?.filter((x) => x.sessionId !== sid) || null;
|
|
2729
|
+
go(`#/s/${ns.id}`);
|
|
2730
|
+
} catch (err) { toast(err.message, 4200); btn.disabled = false; }
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2471
2733
|
case 'manage-on': manage = new Set(); return render();
|
|
2472
2734
|
case 'manage-off': manage = null; return render();
|
|
2473
2735
|
case 'manage-del': return manageDelete();
|
|
@@ -2586,7 +2848,7 @@ const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
|
2586
2848
|
const PAGE = '.page .top, .top';
|
|
2587
2849
|
const TSTEPS = [
|
|
2588
2850
|
{ center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex, Grok, Antigravity…) del teu ordinador o d’un servidor. Et faig un recorregut per tot nio, amb un clic a cada apartat. Dura uns dos minuts.', next: 'Comencem' },
|
|
2589
|
-
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: 'Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (
|
|
2851
|
+
{ route: 'ordinadors', target: '#addComputer, .first-run [data-act=add-computer]', m: '#addComputer, .first-run [data-act=add-computer]', title: 'Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (amb l’ordre d’instal·lació), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
|
|
2590
2852
|
{ route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: 'Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
|
|
2591
2853
|
{ route: 'ordinadors', skipIf: () => !machine(), target: '#screenSec', m: '#screenSec', title: 'Mira i controla la pantalla', text: 'Aquí actives «Apps de l’escriptori» i el navegador dels agents. Amb l’escriptori actiu pots veure la pantalla de l’ordinador i fer-hi captures des del mòbil.' },
|
|
2592
2854
|
{ route: 'ordinadors', mobileOnly: true, tapOnly: '#tabbar [data-act=pantalla-tab]', target: '#tabbar [data-act=pantalla-tab]', m: '#tabbar [data-act=pantalla-tab]', title: 'Pantalla, a un toc', text: 'La pestanya «Pantalla» obre l’escriptori de l’ordinador al mòbil, com un escriptori remot, amb zoom. Si encara no està activat, t’ajuda a activar-lo.' },
|