refacil-sdd-ai 2.8.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -1
- package/bin/cli.js +43 -1
- package/lib/bus/broker.js +51 -4
- package/lib/bus/ui/app.js +302 -0
- package/lib/bus/ui/index.html +58 -0
- package/lib/bus/ui/style.css +122 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -679,11 +679,39 @@ refacil-sdd-ai bus start # Arranca el broker (opcional; las skills
|
|
|
679
679
|
refacil-sdd-ai bus stop # Detiene el broker
|
|
680
680
|
refacil-sdd-ai bus status # Puerto, pid, uptime
|
|
681
681
|
refacil-sdd-ai bus rooms # Salas activas + miembros
|
|
682
|
-
refacil-sdd-ai bus watch <session> # Panel en vivo (sin tokens)
|
|
682
|
+
refacil-sdd-ai bus watch <session> # Panel en vivo en terminal (sin tokens)
|
|
683
|
+
refacil-sdd-ai bus view # Abre la UI web en el navegador (sin tokens)
|
|
683
684
|
refacil-sdd-ai bus leave # Salir de la sala (limpieza manual)
|
|
684
685
|
refacil-sdd-ai bus history [--n N] # Ultimos N mensajes
|
|
685
686
|
```
|
|
686
687
|
|
|
688
|
+
### Vista web en vivo — `bus view`
|
|
689
|
+
|
|
690
|
+
Ademas del panel en terminal (`bus watch`), refacil-bus incluye una UI web minimalista. Se abre con:
|
|
691
|
+
|
|
692
|
+
```bash
|
|
693
|
+
refacil-sdd-ai bus view
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
El broker (que ya expone el puerto `127.0.0.1:7821` para WebSocket) tambien sirve HTTP desde ahi — **cero deps nuevas, cero servicios externos**. El comando:
|
|
697
|
+
|
|
698
|
+
1. Auto-arranca el broker si no estaba corriendo
|
|
699
|
+
2. Abre el navegador por default del OS en `http://127.0.0.1:7821/`
|
|
700
|
+
3. Imprime la URL por stdout por si el navegador no abre solo
|
|
701
|
+
|
|
702
|
+
**Que muestra la UI**:
|
|
703
|
+
|
|
704
|
+
- Sidebar con lista de salas activas y sus miembros
|
|
705
|
+
- Feed en vivo con todos los mensajes, coloreados por tipo (`ask` cian, `reply` verde, `say` ambar, `system` gris)
|
|
706
|
+
- Indicador visual de menciones dirigidas (`→ @<session>`)
|
|
707
|
+
- Emparejamiento ask/reply: cada pregunta muestra `pending` hasta que llega la respuesta con su `correlationId`, entonces cambia a `answered`
|
|
708
|
+
- Filtros por tipo (ocultar system, ver solo ask/reply, etc.)
|
|
709
|
+
- Totalizadores fijos abajo: mensajes recibidos, pares cerrados, preguntas pendientes
|
|
710
|
+
- Selector de sala o vista "todas las salas" en la sidebar
|
|
711
|
+
- Auto-scroll al ultimo mensaje; si haces scroll arriba para ver historial, no te interrumpe con los nuevos
|
|
712
|
+
|
|
713
|
+
**Util para**: presentar la feature al equipo, hacer demos didacticas, supervisar sesiones en paralelo mientras trabajas, ver en tiempo real que pasa cuando dos agentes conversan. **No consume tokens** — es un observador puro vía WebSocket read-only.
|
|
714
|
+
|
|
687
715
|
### Presentacion automatica al hacer join
|
|
688
716
|
|
|
689
717
|
Al unirse a una sala, cada sesion envia un mensaje de presentacion. Para que sea util, la skill `/refacil:join` instruye al LLM a generar un bloque en `AGENTS.md` la primera vez, entre los marcadores:
|
package/bin/cli.js
CHANGED
|
@@ -1097,6 +1097,44 @@ async function busWatchCmd(positional, args) {
|
|
|
1097
1097
|
}
|
|
1098
1098
|
}
|
|
1099
1099
|
|
|
1100
|
+
function openInBrowser(url) {
|
|
1101
|
+
const { spawn } = require('child_process');
|
|
1102
|
+
const platform = process.platform;
|
|
1103
|
+
let cmd;
|
|
1104
|
+
let args;
|
|
1105
|
+
if (platform === 'win32') {
|
|
1106
|
+
cmd = 'cmd';
|
|
1107
|
+
args = ['/c', 'start', '""', url];
|
|
1108
|
+
} else if (platform === 'darwin') {
|
|
1109
|
+
cmd = 'open';
|
|
1110
|
+
args = [url];
|
|
1111
|
+
} else {
|
|
1112
|
+
cmd = 'xdg-open';
|
|
1113
|
+
args = [url];
|
|
1114
|
+
}
|
|
1115
|
+
try {
|
|
1116
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
1117
|
+
return true;
|
|
1118
|
+
} catch (_) {
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function busView() {
|
|
1124
|
+
try {
|
|
1125
|
+
const { info } = await busSpawn.ensureBroker(packageRoot);
|
|
1126
|
+
const url = `http://127.0.0.1:${info.port}/`;
|
|
1127
|
+
console.log(` refacil-bus view disponible en: ${url}`);
|
|
1128
|
+
const opened = openInBrowser(url);
|
|
1129
|
+
if (!opened) {
|
|
1130
|
+
console.log(' (no se pudo abrir el navegador automáticamente, abre la URL manualmente)');
|
|
1131
|
+
}
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
console.error(` No se pudo iniciar la vista: ${err.message}`);
|
|
1134
|
+
process.exit(1);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1100
1138
|
async function busRooms() {
|
|
1101
1139
|
const { ws } = await connectOrDie();
|
|
1102
1140
|
const reply = await busClient.sendAndWait(
|
|
@@ -1171,8 +1209,11 @@ function handleBusSubcommand(sub) {
|
|
|
1171
1209
|
case 'attend':
|
|
1172
1210
|
busAttend(args);
|
|
1173
1211
|
break;
|
|
1212
|
+
case 'view':
|
|
1213
|
+
busView();
|
|
1214
|
+
break;
|
|
1174
1215
|
default:
|
|
1175
|
-
console.log('Uso: refacil-sdd-ai bus <start|stop|status|serve|join|leave|say|ask|reply|history|inbox|rooms|watch|attend>');
|
|
1216
|
+
console.log('Uso: refacil-sdd-ai bus <start|stop|status|serve|join|leave|say|ask|reply|history|inbox|rooms|watch|attend|view>');
|
|
1176
1217
|
}
|
|
1177
1218
|
}
|
|
1178
1219
|
|
|
@@ -1206,6 +1247,7 @@ function help() {
|
|
|
1206
1247
|
bus rooms
|
|
1207
1248
|
bus watch <session> [--room <sala>] (panel en vivo, sin tokens)
|
|
1208
1249
|
bus attend [--timeout N] (escucha preguntas dirigidas)
|
|
1250
|
+
bus view (abre la UI web en el navegador)
|
|
1209
1251
|
clean Elimina skills y remueve hooks SDD-AI de .claude/settings.json
|
|
1210
1252
|
help Muestra esta ayuda
|
|
1211
1253
|
|
package/lib/bus/broker.js
CHANGED
|
@@ -139,10 +139,11 @@ function broadcast(state, roomName, msg, exceptSession = null) {
|
|
|
139
139
|
for (const sockets of s.wss) send(sockets, { type: 'msg', ...msg });
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
// Watchers no están en rooms, pero sí en sessions con watchOnly=true
|
|
142
|
+
// Watchers no están en rooms, pero sí en sessions con watchOnly=true.
|
|
143
|
+
// Si watchRoom es null, recibe msgs de TODAS las salas (modo UI / observador global).
|
|
143
144
|
for (const [, s] of state.sessions.entries()) {
|
|
144
145
|
if (!s.watchOnly) continue;
|
|
145
|
-
if (s.watchRoom
|
|
146
|
+
if (s.watchRoom === null || s.watchRoom === undefined || s.watchRoom === roomName) {
|
|
146
147
|
if (s.ws) send(s.ws, { type: 'msg', ...msg });
|
|
147
148
|
}
|
|
148
149
|
}
|
|
@@ -205,6 +206,7 @@ function handleJoin(state, ws, data) {
|
|
|
205
206
|
|
|
206
207
|
// Si la sesión ya existe en otra sala, sacarla primero
|
|
207
208
|
const existing = state.sessions.get(session);
|
|
209
|
+
const isReturningToSameRoom = !!(existing && existing.room === room);
|
|
208
210
|
if (existing && existing.room && existing.room !== room) {
|
|
209
211
|
leaveRoom(state, session);
|
|
210
212
|
}
|
|
@@ -212,8 +214,13 @@ function handleJoin(state, ws, data) {
|
|
|
212
214
|
const meta = existing || {};
|
|
213
215
|
meta.repo = repo || meta.repo || null;
|
|
214
216
|
meta.room = room;
|
|
215
|
-
meta.lastSeen = nowIso();
|
|
216
217
|
meta.watchOnly = false;
|
|
218
|
+
// Primer join o cambio de sala → lastSeen = ahora (sin backlog).
|
|
219
|
+
// Reingreso a la misma sala tras desconectar → preservar lastSeen existente
|
|
220
|
+
// para que /inbox traiga los mensajes perdidos mientras estuvo fuera.
|
|
221
|
+
if (!isReturningToSameRoom || !meta.lastSeen) {
|
|
222
|
+
meta.lastSeen = nowIso();
|
|
223
|
+
}
|
|
217
224
|
if (!meta.wss) meta.wss = new Set();
|
|
218
225
|
state.sessions.set(session, meta);
|
|
219
226
|
ws._refacilSession = session;
|
|
@@ -439,6 +446,39 @@ function onClose(state, ws) {
|
|
|
439
446
|
// "estar unido" sobrevive entre invocaciones. Solo `leave` explícito saca.
|
|
440
447
|
}
|
|
441
448
|
|
|
449
|
+
const UI_DIR = path.join(__dirname, 'ui');
|
|
450
|
+
const UI_MIME = {
|
|
451
|
+
'.html': 'text/html; charset=utf-8',
|
|
452
|
+
'.css': 'text/css; charset=utf-8',
|
|
453
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
454
|
+
'.svg': 'image/svg+xml',
|
|
455
|
+
'.png': 'image/png',
|
|
456
|
+
'.ico': 'image/x-icon',
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
function serveUi(req, res) {
|
|
460
|
+
let urlPath = req.url.split('?')[0];
|
|
461
|
+
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
|
|
462
|
+
const safePath = urlPath.replace(/^\/+/, '').replace(/\.\./g, '');
|
|
463
|
+
const filePath = path.join(UI_DIR, safePath);
|
|
464
|
+
if (!filePath.startsWith(UI_DIR)) {
|
|
465
|
+
res.writeHead(403);
|
|
466
|
+
res.end('forbidden');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
fs.readFile(filePath, (err, data) => {
|
|
470
|
+
if (err) {
|
|
471
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
472
|
+
res.end('not found');
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
476
|
+
const mime = UI_MIME[ext] || 'application/octet-stream';
|
|
477
|
+
res.writeHead(200, { 'content-type': mime });
|
|
478
|
+
res.end(data);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
442
482
|
async function start() {
|
|
443
483
|
if (!WebSocketServer) {
|
|
444
484
|
throw new Error(
|
|
@@ -466,7 +506,14 @@ async function start() {
|
|
|
466
506
|
// sin persistencia previa
|
|
467
507
|
}
|
|
468
508
|
|
|
469
|
-
const server = http.createServer()
|
|
509
|
+
const server = http.createServer((req, res) => {
|
|
510
|
+
if (req.method === 'GET') {
|
|
511
|
+
serveUi(req, res);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
res.writeHead(405, { 'content-type': 'text/plain' });
|
|
515
|
+
res.end('method not allowed');
|
|
516
|
+
});
|
|
470
517
|
|
|
471
518
|
const port = await pickPort(server);
|
|
472
519
|
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const els = {
|
|
3
|
+
connDot: document.getElementById('conn-dot'),
|
|
4
|
+
connLabel: document.getElementById('conn-label'),
|
|
5
|
+
port: document.getElementById('port'),
|
|
6
|
+
roomsList: document.getElementById('rooms-list'),
|
|
7
|
+
roomsCount: document.getElementById('rooms-count'),
|
|
8
|
+
msgCount: document.getElementById('msg-count'),
|
|
9
|
+
pairCount: document.getElementById('pair-count'),
|
|
10
|
+
pendingCount: document.getElementById('pending-count'),
|
|
11
|
+
feedTitle: document.getElementById('feed-title'),
|
|
12
|
+
feedBody: document.getElementById('feed-body'),
|
|
13
|
+
feedEmpty: document.getElementById('feed-empty'),
|
|
14
|
+
clearBtn: document.getElementById('clear-btn'),
|
|
15
|
+
filters: {
|
|
16
|
+
ask: document.getElementById('filter-ask'),
|
|
17
|
+
reply: document.getElementById('filter-reply'),
|
|
18
|
+
say: document.getElementById('filter-say'),
|
|
19
|
+
system: document.getElementById('filter-system'),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const state = {
|
|
24
|
+
ws: null,
|
|
25
|
+
port: location.port || '7821',
|
|
26
|
+
messages: [],
|
|
27
|
+
messageIds: new Set(),
|
|
28
|
+
rooms: {},
|
|
29
|
+
knownRooms: new Set(),
|
|
30
|
+
selectedRoom: '*',
|
|
31
|
+
askIndex: new Map(),
|
|
32
|
+
answeredCorrelations: new Set(),
|
|
33
|
+
msgCount: 0,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
els.port.textContent = 'puerto ' + state.port;
|
|
37
|
+
|
|
38
|
+
function setConn(kind, label) {
|
|
39
|
+
els.connDot.className = 'dot ' + (kind || '');
|
|
40
|
+
els.connLabel.textContent = label;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatTime(ts) {
|
|
44
|
+
try {
|
|
45
|
+
const d = new Date(ts);
|
|
46
|
+
return d.toLocaleTimeString('es', { hour12: false });
|
|
47
|
+
} catch (_) {
|
|
48
|
+
return ts || '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function kindLabel(m) {
|
|
53
|
+
if (m.kind === 'ask') return 'ask';
|
|
54
|
+
if (m.kind === 'reply') return 'reply';
|
|
55
|
+
if (m.kind === 'broadcast') return 'say';
|
|
56
|
+
return m.kind || 'msg';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function filterAllows(m) {
|
|
60
|
+
if (m.kind === 'ask') return els.filters.ask.checked;
|
|
61
|
+
if (m.kind === 'reply') return els.filters.reply.checked;
|
|
62
|
+
if (m.kind === 'broadcast') return els.filters.say.checked;
|
|
63
|
+
if (m.kind === 'system') return els.filters.system.checked;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function roomMatches(m) {
|
|
68
|
+
return state.selectedRoom === '*' || m.room === state.selectedRoom;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderMsg(m) {
|
|
72
|
+
if (!roomMatches(m)) return null;
|
|
73
|
+
if (!filterAllows(m)) return null;
|
|
74
|
+
const div = document.createElement('div');
|
|
75
|
+
const classes = ['msg', m.kind || 'msg'];
|
|
76
|
+
// mention: lo marcamos si `to` coincide con alguien conocido de la sala
|
|
77
|
+
// (simple: si hay `to` lo consideramos mention visual).
|
|
78
|
+
if (m.to) classes.push('mention');
|
|
79
|
+
div.className = classes.join(' ');
|
|
80
|
+
div.dataset.corr = m.correlationId || '';
|
|
81
|
+
div.dataset.kind = m.kind || '';
|
|
82
|
+
|
|
83
|
+
const head = document.createElement('div');
|
|
84
|
+
head.className = 'msg-head';
|
|
85
|
+
const left = document.createElement('div');
|
|
86
|
+
left.innerHTML =
|
|
87
|
+
`<span class="msg-from">${escape(m.from || '?')}</span>` +
|
|
88
|
+
(m.to ? ` <span class="msg-to">${escape(m.to)}</span>` : '') +
|
|
89
|
+
` <span class="msg-kind ${m.kind || ''}">${kindLabel(m)}</span>`;
|
|
90
|
+
const right = document.createElement('div');
|
|
91
|
+
right.innerHTML =
|
|
92
|
+
`<span class="msg-room">${escape(m.room || '')}</span> ` +
|
|
93
|
+
`<span class="msg-ts">${formatTime(m.ts)}</span>`;
|
|
94
|
+
head.appendChild(left);
|
|
95
|
+
head.appendChild(right);
|
|
96
|
+
|
|
97
|
+
const body = document.createElement('div');
|
|
98
|
+
body.className = 'msg-text';
|
|
99
|
+
body.textContent = m.text || '';
|
|
100
|
+
|
|
101
|
+
div.appendChild(head);
|
|
102
|
+
div.appendChild(body);
|
|
103
|
+
|
|
104
|
+
if (m.correlationId) {
|
|
105
|
+
const corr = document.createElement('div');
|
|
106
|
+
const isAnswered = state.answeredCorrelations.has(m.correlationId);
|
|
107
|
+
corr.className = 'msg-corr ' + (m.kind === 'ask' ? (isAnswered ? 'answered' : 'pending') : '');
|
|
108
|
+
corr.textContent = m.correlationId.slice(0, 8);
|
|
109
|
+
div.appendChild(corr);
|
|
110
|
+
}
|
|
111
|
+
return div;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function escape(s) {
|
|
115
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
116
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
117
|
+
}[c]));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function removeEmptyPlaceholder() {
|
|
121
|
+
const emp = els.feedBody.querySelector('.empty');
|
|
122
|
+
if (emp) emp.remove();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isNearBottom(el, slack = 150) {
|
|
126
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight < slack;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function scrollToBottom(el) {
|
|
130
|
+
el.scrollTop = el.scrollHeight;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ingestMsg(m) {
|
|
134
|
+
if (!m || !m.id) return false;
|
|
135
|
+
if (state.messageIds.has(m.id)) return false;
|
|
136
|
+
state.messageIds.add(m.id);
|
|
137
|
+
state.messages.push(m);
|
|
138
|
+
state.msgCount++;
|
|
139
|
+
if (m.kind === 'ask' && m.correlationId) {
|
|
140
|
+
state.askIndex.set(m.correlationId, m);
|
|
141
|
+
}
|
|
142
|
+
if (m.kind === 'reply' && m.correlationId) {
|
|
143
|
+
state.answeredCorrelations.add(m.correlationId);
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function appendMsg(m) {
|
|
149
|
+
if (!ingestMsg(m)) return; // dedup
|
|
150
|
+
if (m.kind === 'reply' && m.correlationId) {
|
|
151
|
+
const prev = els.feedBody.querySelector(`.msg.ask[data-corr="${m.correlationId}"] .msg-corr`);
|
|
152
|
+
if (prev) { prev.className = 'msg-corr answered'; prev.textContent = m.correlationId.slice(0, 8); }
|
|
153
|
+
}
|
|
154
|
+
const node = renderMsg(m);
|
|
155
|
+
if (node) {
|
|
156
|
+
const pinnedBottom = isNearBottom(els.feedBody);
|
|
157
|
+
removeEmptyPlaceholder();
|
|
158
|
+
els.feedBody.appendChild(node);
|
|
159
|
+
if (pinnedBottom) scrollToBottom(els.feedBody);
|
|
160
|
+
}
|
|
161
|
+
updateStats();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ingestHistory(messages) {
|
|
165
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
166
|
+
let added = 0;
|
|
167
|
+
for (const m of messages) {
|
|
168
|
+
if (ingestMsg(m)) added++;
|
|
169
|
+
}
|
|
170
|
+
if (!added) return;
|
|
171
|
+
// Re-ordenar por ts (los históricos pueden llegar mezclados con el live).
|
|
172
|
+
state.messages.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
|
|
173
|
+
rerenderFeed();
|
|
174
|
+
updateStats();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function updateStats() {
|
|
178
|
+
els.msgCount.textContent = state.msgCount;
|
|
179
|
+
els.pairCount.textContent = state.answeredCorrelations.size;
|
|
180
|
+
let pending = 0;
|
|
181
|
+
for (const [cid] of state.askIndex) {
|
|
182
|
+
if (!state.answeredCorrelations.has(cid)) pending++;
|
|
183
|
+
}
|
|
184
|
+
els.pendingCount.textContent = pending;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderRooms() {
|
|
188
|
+
const names = Object.keys(state.rooms);
|
|
189
|
+
els.roomsCount.textContent = names.length;
|
|
190
|
+
els.roomsList.innerHTML = '';
|
|
191
|
+
|
|
192
|
+
const liAll = document.createElement('li');
|
|
193
|
+
liAll.className = 'all' + (state.selectedRoom === '*' ? ' active' : '');
|
|
194
|
+
liAll.innerHTML = `<div class="room-name">★ todas las salas</div><div class="members">${state.msgCount} mensajes totales</div>`;
|
|
195
|
+
liAll.addEventListener('click', () => selectRoom('*'));
|
|
196
|
+
els.roomsList.appendChild(liAll);
|
|
197
|
+
|
|
198
|
+
for (const name of names.sort()) {
|
|
199
|
+
const members = state.rooms[name] || [];
|
|
200
|
+
const li = document.createElement('li');
|
|
201
|
+
li.className = state.selectedRoom === name ? 'active' : '';
|
|
202
|
+
li.innerHTML = `<div class="room-name">${escape(name)}</div>` +
|
|
203
|
+
`<div class="members">${members.map((m) => `<span class="member">${escape(m)}</span>`).join('')}</div>`;
|
|
204
|
+
li.addEventListener('click', () => selectRoom(name));
|
|
205
|
+
els.roomsList.appendChild(li);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function selectRoom(room) {
|
|
210
|
+
state.selectedRoom = room;
|
|
211
|
+
els.feedTitle.textContent = room === '*' ? 'Todas las salas' : `Sala: ${room}`;
|
|
212
|
+
renderRooms();
|
|
213
|
+
rerenderFeed();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function rerenderFeed() {
|
|
217
|
+
els.feedBody.innerHTML = '';
|
|
218
|
+
let any = false;
|
|
219
|
+
for (const m of state.messages) {
|
|
220
|
+
const node = renderMsg(m);
|
|
221
|
+
if (node) { els.feedBody.appendChild(node); any = true; }
|
|
222
|
+
}
|
|
223
|
+
if (!any) {
|
|
224
|
+
const e = document.createElement('div');
|
|
225
|
+
e.className = 'empty';
|
|
226
|
+
e.textContent = state.selectedRoom === '*'
|
|
227
|
+
? 'Esperando mensajes...'
|
|
228
|
+
: `Sin mensajes en la sala "${state.selectedRoom}".`;
|
|
229
|
+
els.feedBody.appendChild(e);
|
|
230
|
+
}
|
|
231
|
+
// Tras un rerender (cambio de sala o ingest de history) siempre al último.
|
|
232
|
+
scrollToBottom(els.feedBody);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function wireFilters() {
|
|
236
|
+
Object.values(els.filters).forEach((el) => {
|
|
237
|
+
el.addEventListener('change', rerenderFeed);
|
|
238
|
+
});
|
|
239
|
+
els.clearBtn.addEventListener('click', () => {
|
|
240
|
+
state.messages = [];
|
|
241
|
+
state.askIndex.clear();
|
|
242
|
+
state.answeredCorrelations.clear();
|
|
243
|
+
state.msgCount = 0;
|
|
244
|
+
rerenderFeed();
|
|
245
|
+
updateStats();
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function pollStatus() {
|
|
250
|
+
if (!state.ws || state.ws.readyState !== 1) return;
|
|
251
|
+
state.ws.send(JSON.stringify({ op: 'status' }));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function connect() {
|
|
255
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
256
|
+
const url = `${proto}//${location.host}`;
|
|
257
|
+
setConn('', 'conectando...');
|
|
258
|
+
const ws = new WebSocket(url);
|
|
259
|
+
state.ws = ws;
|
|
260
|
+
|
|
261
|
+
ws.addEventListener('open', () => {
|
|
262
|
+
setConn('ok', 'en vivo');
|
|
263
|
+
// Suscribirse a todas las salas
|
|
264
|
+
ws.send(JSON.stringify({ op: 'watch', room: null }));
|
|
265
|
+
ws.send(JSON.stringify({ op: 'status' }));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
ws.addEventListener('message', (ev) => {
|
|
269
|
+
let data;
|
|
270
|
+
try { data = JSON.parse(ev.data); } catch (_) { return; }
|
|
271
|
+
if (data.type === 'msg') {
|
|
272
|
+
appendMsg(data);
|
|
273
|
+
} else if (data.type === 'system' && data.event === 'status') {
|
|
274
|
+
const detail = data.detail || {};
|
|
275
|
+
state.rooms = detail.rooms || {};
|
|
276
|
+
renderRooms();
|
|
277
|
+
// Pedir history de cada sala nueva detectada
|
|
278
|
+
for (const room of Object.keys(state.rooms)) {
|
|
279
|
+
if (!state.knownRooms.has(room)) {
|
|
280
|
+
state.knownRooms.add(room);
|
|
281
|
+
ws.send(JSON.stringify({ op: 'history', room, n: 100 }));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} else if (data.type === 'history') {
|
|
285
|
+
ingestHistory(data.messages || []);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
ws.addEventListener('close', () => {
|
|
290
|
+
setConn('err', 'desconectado · reintentando...');
|
|
291
|
+
setTimeout(connect, 1500);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
ws.addEventListener('error', () => {
|
|
295
|
+
setConn('err', 'error de conexión');
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
wireFilters();
|
|
300
|
+
connect();
|
|
301
|
+
setInterval(pollStatus, 3000);
|
|
302
|
+
})();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="es">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>refacil-bus · vista en vivo</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<div class="brand">
|
|
12
|
+
<span class="logo">●</span>
|
|
13
|
+
<h1>refacil-bus</h1>
|
|
14
|
+
<span class="tag">vista en vivo</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="status">
|
|
17
|
+
<span id="conn-dot" class="dot"></span>
|
|
18
|
+
<span id="conn-label">conectando...</span>
|
|
19
|
+
<span id="port" class="muted"></span>
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<main>
|
|
24
|
+
<aside id="sidebar">
|
|
25
|
+
<div class="side-head">
|
|
26
|
+
<h2>Salas activas</h2>
|
|
27
|
+
<span id="rooms-count" class="pill">0</span>
|
|
28
|
+
</div>
|
|
29
|
+
<ul id="rooms-list"></ul>
|
|
30
|
+
<div class="side-foot">
|
|
31
|
+
<div class="stat"><span class="label">Mensajes recibidos</span><span id="msg-count" class="val">0</span></div>
|
|
32
|
+
<div class="stat"><span class="label">Pares ask/reply</span><span id="pair-count" class="val">0</span></div>
|
|
33
|
+
<div class="stat"><span class="label">Pendientes</span><span id="pending-count" class="val">0</span></div>
|
|
34
|
+
</div>
|
|
35
|
+
</aside>
|
|
36
|
+
|
|
37
|
+
<section id="feed">
|
|
38
|
+
<div class="feed-head">
|
|
39
|
+
<h2 id="feed-title">Todas las salas</h2>
|
|
40
|
+
<div class="feed-controls">
|
|
41
|
+
<label><input type="checkbox" id="filter-ask" checked> ask</label>
|
|
42
|
+
<label><input type="checkbox" id="filter-reply" checked> reply</label>
|
|
43
|
+
<label><input type="checkbox" id="filter-say" checked> say</label>
|
|
44
|
+
<label><input type="checkbox" id="filter-system" checked> system</label>
|
|
45
|
+
<button id="clear-btn" title="limpiar vista (no borra el historial)">limpiar</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div id="feed-body">
|
|
49
|
+
<div id="feed-empty" class="empty">
|
|
50
|
+
Esperando mensajes... si tus sesiones están en una sala, deberían aparecer aquí cuando alguien envíe algo.
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</section>
|
|
54
|
+
</main>
|
|
55
|
+
|
|
56
|
+
<script src="/app.js"></script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0f1419;
|
|
3
|
+
--panel: #151c23;
|
|
4
|
+
--border: #242c35;
|
|
5
|
+
--muted: #6b7a88;
|
|
6
|
+
--text: #d7dde4;
|
|
7
|
+
--head: #ffffff;
|
|
8
|
+
--accent: #4fc3f7;
|
|
9
|
+
--ask: #4fc3f7;
|
|
10
|
+
--reply: #81c784;
|
|
11
|
+
--say: #ffb74d;
|
|
12
|
+
--system: #78909c;
|
|
13
|
+
--mention: #ffd54f;
|
|
14
|
+
--danger: #ef5350;
|
|
15
|
+
--ok: #66bb6a;
|
|
16
|
+
--pending: #ffa726;
|
|
17
|
+
font-family: -apple-system, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
|
18
|
+
font-size: 14px;
|
|
19
|
+
color: var(--text);
|
|
20
|
+
background: var(--bg);
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
23
|
+
html, body { height: 100vh; overflow: hidden; }
|
|
24
|
+
body { display: flex; flex-direction: column; }
|
|
25
|
+
header {
|
|
26
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
27
|
+
padding: 10px 20px; border-bottom: 1px solid var(--border);
|
|
28
|
+
background: var(--panel);
|
|
29
|
+
}
|
|
30
|
+
.brand { display: flex; align-items: center; gap: 10px; }
|
|
31
|
+
.brand .logo { color: var(--accent); font-size: 22px; }
|
|
32
|
+
.brand h1 { font-size: 16px; font-weight: 600; color: var(--head); }
|
|
33
|
+
.brand .tag { color: var(--muted); font-size: 12px; }
|
|
34
|
+
.status { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
|
35
|
+
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); transition: background .3s; }
|
|
36
|
+
.status .dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
|
37
|
+
.status .dot.err { background: var(--danger); }
|
|
38
|
+
.muted { color: var(--muted); }
|
|
39
|
+
|
|
40
|
+
main { flex: 1; display: flex; overflow: hidden; }
|
|
41
|
+
#sidebar {
|
|
42
|
+
width: 280px; min-width: 280px;
|
|
43
|
+
border-right: 1px solid var(--border);
|
|
44
|
+
background: var(--panel);
|
|
45
|
+
display: flex; flex-direction: column;
|
|
46
|
+
}
|
|
47
|
+
.side-head, .side-foot {
|
|
48
|
+
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
|
49
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
50
|
+
}
|
|
51
|
+
.side-foot {
|
|
52
|
+
border-bottom: none; border-top: 1px solid var(--border); margin-top: auto;
|
|
53
|
+
flex-direction: column; gap: 6px; align-items: stretch;
|
|
54
|
+
}
|
|
55
|
+
.side-head h2 { font-size: 13px; font-weight: 600; color: var(--head); text-transform: uppercase; letter-spacing: .5px; }
|
|
56
|
+
.pill { background: var(--border); color: var(--muted); padding: 2px 8px; border-radius: 10px; font-size: 11px; }
|
|
57
|
+
.stat { display: flex; justify-content: space-between; font-size: 12px; }
|
|
58
|
+
.stat .label { color: var(--muted); }
|
|
59
|
+
.stat .val { color: var(--head); font-weight: 600; }
|
|
60
|
+
|
|
61
|
+
#rooms-list { list-style: none; overflow-y: auto; flex: 1; }
|
|
62
|
+
#rooms-list li {
|
|
63
|
+
padding: 10px 16px; border-bottom: 1px solid var(--border);
|
|
64
|
+
cursor: pointer; transition: background .15s;
|
|
65
|
+
}
|
|
66
|
+
#rooms-list li:hover { background: rgba(255,255,255,.02); }
|
|
67
|
+
#rooms-list li.active { background: rgba(79,195,247,.08); border-left: 2px solid var(--accent); padding-left: 14px; }
|
|
68
|
+
#rooms-list li.all { background: rgba(255,255,255,.03); }
|
|
69
|
+
#rooms-list .room-name { color: var(--head); font-weight: 500; font-size: 13px; margin-bottom: 4px; }
|
|
70
|
+
#rooms-list .members { color: var(--muted); font-size: 11px; line-height: 1.6; }
|
|
71
|
+
#rooms-list .member { display: inline-block; margin-right: 6px; }
|
|
72
|
+
#rooms-list .member::before { content: '● '; color: var(--ok); font-size: 8px; vertical-align: middle; }
|
|
73
|
+
|
|
74
|
+
#feed { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
75
|
+
.feed-head {
|
|
76
|
+
padding: 12px 20px; border-bottom: 1px solid var(--border);
|
|
77
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
78
|
+
background: var(--panel);
|
|
79
|
+
}
|
|
80
|
+
.feed-head h2 { font-size: 14px; color: var(--head); font-weight: 600; }
|
|
81
|
+
.feed-controls { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--muted); }
|
|
82
|
+
.feed-controls label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
|
83
|
+
.feed-controls input { cursor: pointer; }
|
|
84
|
+
#clear-btn {
|
|
85
|
+
background: var(--border); color: var(--muted); border: none;
|
|
86
|
+
padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer;
|
|
87
|
+
}
|
|
88
|
+
#clear-btn:hover { background: rgba(255,255,255,.06); color: var(--text); }
|
|
89
|
+
|
|
90
|
+
#feed-body { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 8px; }
|
|
91
|
+
.empty { color: var(--muted); text-align: center; padding: 40px 20px; font-size: 13px; }
|
|
92
|
+
|
|
93
|
+
.msg {
|
|
94
|
+
background: var(--panel); border-left: 3px solid var(--system);
|
|
95
|
+
padding: 10px 14px; border-radius: 0 6px 6px 0;
|
|
96
|
+
animation: slide-in .25s ease-out;
|
|
97
|
+
}
|
|
98
|
+
@keyframes slide-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
99
|
+
.msg.ask { border-left-color: var(--ask); }
|
|
100
|
+
.msg.reply { border-left-color: var(--reply); margin-left: 30px; }
|
|
101
|
+
.msg.broadcast { border-left-color: var(--say); }
|
|
102
|
+
.msg.system { border-left-color: var(--system); opacity: .65; font-style: italic; }
|
|
103
|
+
.msg.mention { box-shadow: inset 3px 0 0 var(--mention), 0 0 0 1px rgba(255,213,79,.3); }
|
|
104
|
+
|
|
105
|
+
.msg-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px; }
|
|
106
|
+
.msg-from { color: var(--head); font-weight: 600; font-size: 13px; }
|
|
107
|
+
.msg-to { color: var(--mention); font-weight: 500; font-size: 12px; }
|
|
108
|
+
.msg-to::before { content: '→ @'; color: var(--muted); font-weight: 400; }
|
|
109
|
+
.msg-kind { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
|
110
|
+
.msg-kind.ask { color: var(--ask); }
|
|
111
|
+
.msg-kind.reply { color: var(--reply); }
|
|
112
|
+
.msg-kind.broadcast { color: var(--say); }
|
|
113
|
+
.msg-ts { color: var(--text); font-size: 11px; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
114
|
+
.msg-room { color: var(--text); font-size: 11px; font-family: monospace; font-weight: 600; }
|
|
115
|
+
.msg-text { color: var(--text); white-space: pre-wrap; line-height: 1.4; font-size: 13px; }
|
|
116
|
+
.msg-corr {
|
|
117
|
+
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
|
|
118
|
+
font-family: monospace; font-size: 10px; color: var(--muted);
|
|
119
|
+
}
|
|
120
|
+
.msg-corr.pending { color: var(--pending); }
|
|
121
|
+
.msg-corr.pending::before { content: '⏳ pending · '; }
|
|
122
|
+
.msg-corr.answered::before { content: '✓ answered · '; color: var(--ok); }
|
package/package.json
CHANGED