refacil-sdd-ai 4.2.3 → 4.3.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 +239 -214
- package/agents/auditor.md +182 -184
- package/agents/debugger.md +201 -204
- package/agents/implementer.md +150 -149
- package/agents/investigator.md +80 -89
- package/agents/proposer.md +219 -124
- package/agents/tester.md +140 -144
- package/agents/validator.md +153 -145
- package/bin/cli.js +158 -116
- package/lib/bus/askFulfillment.js +17 -17
- package/lib/bus/broker.js +599 -599
- package/lib/bus/ui/app.js +318 -318
- package/lib/commands/sdd.js +433 -0
- package/lib/hooks.js +236 -236
- package/lib/installer.js +55 -1
- package/lib/methodology-migration-pending.js +101 -136
- package/package.json +4 -6
- package/skills/apply/SKILL.md +122 -120
- package/skills/archive/SKILL.md +101 -107
- package/skills/ask/SKILL.md +78 -78
- package/skills/attend/SKILL.md +70 -70
- package/skills/bug/SKILL.md +121 -117
- package/skills/explore/SKILL.md +61 -63
- package/skills/guide/SKILL.md +79 -79
- package/skills/inbox/SKILL.md +43 -43
- package/skills/join/SKILL.md +82 -82
- package/skills/prereqs/BUS-CROSS-REPO.md +55 -55
- package/skills/prereqs/METHODOLOGY-CONTRACT.md +122 -115
- package/skills/prereqs/SKILL.md +30 -37
- package/skills/propose/SKILL.md +91 -102
- package/skills/reply/SKILL.md +44 -44
- package/skills/review/SKILL.md +135 -126
- package/skills/review/checklist-back.md +92 -92
- package/skills/review/checklist-front.md +72 -72
- package/skills/review/checklist.md +114 -114
- package/skills/say/SKILL.md +38 -38
- package/skills/setup/SKILL.md +85 -141
- package/skills/setup/troubleshooting.md +38 -35
- package/skills/test/SKILL.md +86 -94
- package/skills/test/testing-patterns.md +63 -63
- package/skills/up-code/SKILL.md +108 -108
- package/skills/update/SKILL.md +109 -132
- package/skills/verify/SKILL.md +128 -132
- package/templates/compact-guidance.md +45 -45
- package/templates/methodology-guide.md +46 -42
- package/config/openspec-config.yaml +0 -8
- package/skills/prereqs/OPENSPEC-DELTAS.md +0 -51
package/lib/bus/ui/app.js
CHANGED
|
@@ -1,318 +1,318 @@
|
|
|
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
|
-
answeredByResponder: new Set(),
|
|
32
|
-
msgCount: 0,
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function cssEsc(s) {
|
|
36
|
-
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(String(s));
|
|
37
|
-
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isAskAnswered(m) {
|
|
41
|
-
if (m.kind !== 'ask' || !m.correlationId) return false;
|
|
42
|
-
if (m.to) return state.answeredByResponder.has(`${m.correlationId}\t${m.to}`);
|
|
43
|
-
return state.messages.some(
|
|
44
|
-
(r) => r.kind === 'reply' && r.correlationId === m.correlationId,
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
els.port.textContent = 'puerto ' + state.port;
|
|
49
|
-
|
|
50
|
-
function setConn(kind, label) {
|
|
51
|
-
els.connDot.className = 'dot ' + (kind || '');
|
|
52
|
-
els.connLabel.textContent = label;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function formatTime(ts) {
|
|
56
|
-
try {
|
|
57
|
-
const d = new Date(ts);
|
|
58
|
-
return d.toLocaleTimeString('es', { hour12: false });
|
|
59
|
-
} catch (_) {
|
|
60
|
-
return ts || '';
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function kindLabel(m) {
|
|
65
|
-
if (m.kind === 'ask') return 'ask';
|
|
66
|
-
if (m.kind === 'reply') return 'reply';
|
|
67
|
-
if (m.kind === 'broadcast') return 'say';
|
|
68
|
-
return m.kind || 'msg';
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function filterAllows(m) {
|
|
72
|
-
if (m.kind === 'ask') return els.filters.ask.checked;
|
|
73
|
-
if (m.kind === 'reply') return els.filters.reply.checked;
|
|
74
|
-
if (m.kind === 'broadcast') return els.filters.say.checked;
|
|
75
|
-
if (m.kind === 'system') return els.filters.system.checked;
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function roomMatches(m) {
|
|
80
|
-
return state.selectedRoom === '*' || m.room === state.selectedRoom;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function renderMsg(m) {
|
|
84
|
-
if (!roomMatches(m)) return null;
|
|
85
|
-
if (!filterAllows(m)) return null;
|
|
86
|
-
const div = document.createElement('div');
|
|
87
|
-
const classes = ['msg', m.kind || 'msg'];
|
|
88
|
-
// mention: lo marcamos si `to` coincide con alguien conocido de la sala
|
|
89
|
-
// (simple: si hay `to` lo consideramos mention visual).
|
|
90
|
-
if (m.to) classes.push('mention');
|
|
91
|
-
div.className = classes.join(' ');
|
|
92
|
-
div.dataset.corr = m.correlationId || '';
|
|
93
|
-
div.dataset.kind = m.kind || '';
|
|
94
|
-
if (m.kind === 'ask' && m.correlationId && m.to) {
|
|
95
|
-
div.dataset.askTo = m.to;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const head = document.createElement('div');
|
|
99
|
-
head.className = 'msg-head';
|
|
100
|
-
const left = document.createElement('div');
|
|
101
|
-
left.innerHTML =
|
|
102
|
-
`<span class="msg-from">${escape(m.from || '?')}</span>` +
|
|
103
|
-
(m.to ? ` <span class="msg-to">${escape(m.to)}</span>` : '') +
|
|
104
|
-
` <span class="msg-kind ${m.kind || ''}">${kindLabel(m)}</span>`;
|
|
105
|
-
const right = document.createElement('div');
|
|
106
|
-
right.innerHTML =
|
|
107
|
-
`<span class="msg-room">${escape(m.room || '')}</span> ` +
|
|
108
|
-
`<span class="msg-ts">${formatTime(m.ts)}</span>`;
|
|
109
|
-
head.appendChild(left);
|
|
110
|
-
head.appendChild(right);
|
|
111
|
-
|
|
112
|
-
const body = document.createElement('div');
|
|
113
|
-
body.className = 'msg-text';
|
|
114
|
-
body.textContent = m.text || '';
|
|
115
|
-
|
|
116
|
-
div.appendChild(head);
|
|
117
|
-
div.appendChild(body);
|
|
118
|
-
|
|
119
|
-
if (m.correlationId) {
|
|
120
|
-
const corr = document.createElement('div');
|
|
121
|
-
const isAnswered = m.kind === 'ask' ? isAskAnswered(m) : false;
|
|
122
|
-
corr.className = 'msg-corr ' + (m.kind === 'ask' ? (isAnswered ? 'answered' : 'pending') : '');
|
|
123
|
-
corr.textContent = m.correlationId.slice(0, 8);
|
|
124
|
-
div.appendChild(corr);
|
|
125
|
-
}
|
|
126
|
-
return div;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function escape(s) {
|
|
130
|
-
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
131
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
132
|
-
}[c]));
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function removeEmptyPlaceholder() {
|
|
136
|
-
const emp = els.feedBody.querySelector('.empty');
|
|
137
|
-
if (emp) emp.remove();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function isNearBottom(el, slack = 150) {
|
|
141
|
-
return el.scrollHeight - el.scrollTop - el.clientHeight < slack;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function scrollToBottom(el) {
|
|
145
|
-
el.scrollTop = el.scrollHeight;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function ingestMsg(m) {
|
|
149
|
-
if (!m || !m.id) return false;
|
|
150
|
-
if (state.messageIds.has(m.id)) return false;
|
|
151
|
-
state.messageIds.add(m.id);
|
|
152
|
-
state.messages.push(m);
|
|
153
|
-
state.msgCount++;
|
|
154
|
-
if (m.kind === 'reply' && m.correlationId && m.from) {
|
|
155
|
-
state.answeredByResponder.add(`${m.correlationId}\t${m.from}`);
|
|
156
|
-
}
|
|
157
|
-
return true;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function appendMsg(m) {
|
|
161
|
-
if (!ingestMsg(m)) return; // dedup
|
|
162
|
-
if (m.kind === 'reply' && m.correlationId && m.from) {
|
|
163
|
-
const sel = `.msg.ask[data-corr="${cssEsc(m.correlationId)}"][data-ask-to="${cssEsc(m.from)}"] .msg-corr`;
|
|
164
|
-
const prev = els.feedBody.querySelector(sel);
|
|
165
|
-
if (prev) {
|
|
166
|
-
prev.className = 'msg-corr answered';
|
|
167
|
-
prev.textContent = m.correlationId.slice(0, 8);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
const node = renderMsg(m);
|
|
171
|
-
if (node) {
|
|
172
|
-
const pinnedBottom = isNearBottom(els.feedBody);
|
|
173
|
-
removeEmptyPlaceholder();
|
|
174
|
-
els.feedBody.appendChild(node);
|
|
175
|
-
if (pinnedBottom) scrollToBottom(els.feedBody);
|
|
176
|
-
}
|
|
177
|
-
updateStats();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function ingestHistory(messages) {
|
|
181
|
-
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
182
|
-
let added = 0;
|
|
183
|
-
for (const m of messages) {
|
|
184
|
-
if (ingestMsg(m)) added++;
|
|
185
|
-
}
|
|
186
|
-
if (!added) return;
|
|
187
|
-
// Re-ordenar por ts (los históricos pueden llegar mezclados con el live).
|
|
188
|
-
state.messages.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
|
|
189
|
-
rerenderFeed();
|
|
190
|
-
updateStats();
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function updateStats() {
|
|
194
|
-
els.msgCount.textContent = state.msgCount;
|
|
195
|
-
els.pairCount.textContent = state.messages.filter((x) => x.kind === 'reply').length;
|
|
196
|
-
let pending = 0;
|
|
197
|
-
for (const m of state.messages) {
|
|
198
|
-
if (m.kind === 'ask' && !isAskAnswered(m)) pending++;
|
|
199
|
-
}
|
|
200
|
-
els.pendingCount.textContent = pending;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function renderRooms() {
|
|
204
|
-
const names = Object.keys(state.rooms);
|
|
205
|
-
els.roomsCount.textContent = names.length;
|
|
206
|
-
els.roomsList.innerHTML = '';
|
|
207
|
-
|
|
208
|
-
const liAll = document.createElement('li');
|
|
209
|
-
liAll.className = 'all' + (state.selectedRoom === '*' ? ' active' : '');
|
|
210
|
-
liAll.innerHTML = `<div class="room-name">★ todas las salas</div><div class="members">${state.msgCount} mensajes totales</div>`;
|
|
211
|
-
liAll.addEventListener('click', () => selectRoom('*'));
|
|
212
|
-
els.roomsList.appendChild(liAll);
|
|
213
|
-
|
|
214
|
-
for (const name of names.sort()) {
|
|
215
|
-
const members = state.rooms[name] || [];
|
|
216
|
-
const li = document.createElement('li');
|
|
217
|
-
li.className = state.selectedRoom === name ? 'active' : '';
|
|
218
|
-
li.innerHTML = `<div class="room-name">${escape(name)}</div>` +
|
|
219
|
-
`<div class="members">${members.map((m) => `<span class="member">${escape(m)}</span>`).join('')}</div>`;
|
|
220
|
-
li.addEventListener('click', () => selectRoom(name));
|
|
221
|
-
els.roomsList.appendChild(li);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function selectRoom(room) {
|
|
226
|
-
state.selectedRoom = room;
|
|
227
|
-
els.feedTitle.textContent = room === '*' ? 'Todas las salas' : `Sala: ${room}`;
|
|
228
|
-
renderRooms();
|
|
229
|
-
rerenderFeed();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function rerenderFeed() {
|
|
233
|
-
els.feedBody.innerHTML = '';
|
|
234
|
-
let any = false;
|
|
235
|
-
for (const m of state.messages) {
|
|
236
|
-
const node = renderMsg(m);
|
|
237
|
-
if (node) { els.feedBody.appendChild(node); any = true; }
|
|
238
|
-
}
|
|
239
|
-
if (!any) {
|
|
240
|
-
const e = document.createElement('div');
|
|
241
|
-
e.className = 'empty';
|
|
242
|
-
e.textContent = state.selectedRoom === '*'
|
|
243
|
-
? 'Esperando mensajes...'
|
|
244
|
-
: `Sin mensajes en la sala "${state.selectedRoom}".`;
|
|
245
|
-
els.feedBody.appendChild(e);
|
|
246
|
-
}
|
|
247
|
-
// Tras un rerender (cambio de sala o ingest de history) siempre al último.
|
|
248
|
-
scrollToBottom(els.feedBody);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function wireFilters() {
|
|
252
|
-
Object.values(els.filters).forEach((el) => {
|
|
253
|
-
el.addEventListener('change', rerenderFeed);
|
|
254
|
-
});
|
|
255
|
-
els.clearBtn.addEventListener('click', () => {
|
|
256
|
-
state.messages = [];
|
|
257
|
-
state.messageIds.clear();
|
|
258
|
-
state.answeredByResponder.clear();
|
|
259
|
-
state.msgCount = 0;
|
|
260
|
-
rerenderFeed();
|
|
261
|
-
updateStats();
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async function pollStatus() {
|
|
266
|
-
if (!state.ws || state.ws.readyState !== 1) return;
|
|
267
|
-
state.ws.send(JSON.stringify({ op: 'status' }));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function connect() {
|
|
271
|
-
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
272
|
-
const url = `${proto}//${location.host}`;
|
|
273
|
-
setConn('', 'conectando...');
|
|
274
|
-
const ws = new WebSocket(url);
|
|
275
|
-
state.ws = ws;
|
|
276
|
-
|
|
277
|
-
ws.addEventListener('open', () => {
|
|
278
|
-
setConn('ok', 'en vivo');
|
|
279
|
-
// Suscribirse a todas las salas
|
|
280
|
-
ws.send(JSON.stringify({ op: 'watch', room: null }));
|
|
281
|
-
ws.send(JSON.stringify({ op: 'status' }));
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
ws.addEventListener('message', (ev) => {
|
|
285
|
-
let data;
|
|
286
|
-
try { data = JSON.parse(ev.data); } catch (_) { return; }
|
|
287
|
-
if (data.type === 'msg') {
|
|
288
|
-
appendMsg(data);
|
|
289
|
-
} else if (data.type === 'system' && data.event === 'status') {
|
|
290
|
-
const detail = data.detail || {};
|
|
291
|
-
state.rooms = detail.rooms || {};
|
|
292
|
-
renderRooms();
|
|
293
|
-
// Pedir history de cada sala nueva detectada
|
|
294
|
-
for (const room of Object.keys(state.rooms)) {
|
|
295
|
-
if (!state.knownRooms.has(room)) {
|
|
296
|
-
state.knownRooms.add(room);
|
|
297
|
-
ws.send(JSON.stringify({ op: 'history', room, n: 100 }));
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
} else if (data.type === 'history') {
|
|
301
|
-
ingestHistory(data.messages || []);
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
ws.addEventListener('close', () => {
|
|
306
|
-
setConn('err', 'desconectado · reintentando...');
|
|
307
|
-
setTimeout(connect, 1500);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
ws.addEventListener('error', () => {
|
|
311
|
-
setConn('err', 'error de conexión');
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
wireFilters();
|
|
316
|
-
connect();
|
|
317
|
-
setInterval(pollStatus, 3000);
|
|
318
|
-
})();
|
|
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
|
+
answeredByResponder: new Set(),
|
|
32
|
+
msgCount: 0,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function cssEsc(s) {
|
|
36
|
+
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(String(s));
|
|
37
|
+
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isAskAnswered(m) {
|
|
41
|
+
if (m.kind !== 'ask' || !m.correlationId) return false;
|
|
42
|
+
if (m.to) return state.answeredByResponder.has(`${m.correlationId}\t${m.to}`);
|
|
43
|
+
return state.messages.some(
|
|
44
|
+
(r) => r.kind === 'reply' && r.correlationId === m.correlationId,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
els.port.textContent = 'puerto ' + state.port;
|
|
49
|
+
|
|
50
|
+
function setConn(kind, label) {
|
|
51
|
+
els.connDot.className = 'dot ' + (kind || '');
|
|
52
|
+
els.connLabel.textContent = label;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatTime(ts) {
|
|
56
|
+
try {
|
|
57
|
+
const d = new Date(ts);
|
|
58
|
+
return d.toLocaleTimeString('es', { hour12: false });
|
|
59
|
+
} catch (_) {
|
|
60
|
+
return ts || '';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function kindLabel(m) {
|
|
65
|
+
if (m.kind === 'ask') return 'ask';
|
|
66
|
+
if (m.kind === 'reply') return 'reply';
|
|
67
|
+
if (m.kind === 'broadcast') return 'say';
|
|
68
|
+
return m.kind || 'msg';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function filterAllows(m) {
|
|
72
|
+
if (m.kind === 'ask') return els.filters.ask.checked;
|
|
73
|
+
if (m.kind === 'reply') return els.filters.reply.checked;
|
|
74
|
+
if (m.kind === 'broadcast') return els.filters.say.checked;
|
|
75
|
+
if (m.kind === 'system') return els.filters.system.checked;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function roomMatches(m) {
|
|
80
|
+
return state.selectedRoom === '*' || m.room === state.selectedRoom;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderMsg(m) {
|
|
84
|
+
if (!roomMatches(m)) return null;
|
|
85
|
+
if (!filterAllows(m)) return null;
|
|
86
|
+
const div = document.createElement('div');
|
|
87
|
+
const classes = ['msg', m.kind || 'msg'];
|
|
88
|
+
// mention: lo marcamos si `to` coincide con alguien conocido de la sala
|
|
89
|
+
// (simple: si hay `to` lo consideramos mention visual).
|
|
90
|
+
if (m.to) classes.push('mention');
|
|
91
|
+
div.className = classes.join(' ');
|
|
92
|
+
div.dataset.corr = m.correlationId || '';
|
|
93
|
+
div.dataset.kind = m.kind || '';
|
|
94
|
+
if (m.kind === 'ask' && m.correlationId && m.to) {
|
|
95
|
+
div.dataset.askTo = m.to;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const head = document.createElement('div');
|
|
99
|
+
head.className = 'msg-head';
|
|
100
|
+
const left = document.createElement('div');
|
|
101
|
+
left.innerHTML =
|
|
102
|
+
`<span class="msg-from">${escape(m.from || '?')}</span>` +
|
|
103
|
+
(m.to ? ` <span class="msg-to">${escape(m.to)}</span>` : '') +
|
|
104
|
+
` <span class="msg-kind ${m.kind || ''}">${kindLabel(m)}</span>`;
|
|
105
|
+
const right = document.createElement('div');
|
|
106
|
+
right.innerHTML =
|
|
107
|
+
`<span class="msg-room">${escape(m.room || '')}</span> ` +
|
|
108
|
+
`<span class="msg-ts">${formatTime(m.ts)}</span>`;
|
|
109
|
+
head.appendChild(left);
|
|
110
|
+
head.appendChild(right);
|
|
111
|
+
|
|
112
|
+
const body = document.createElement('div');
|
|
113
|
+
body.className = 'msg-text';
|
|
114
|
+
body.textContent = m.text || '';
|
|
115
|
+
|
|
116
|
+
div.appendChild(head);
|
|
117
|
+
div.appendChild(body);
|
|
118
|
+
|
|
119
|
+
if (m.correlationId) {
|
|
120
|
+
const corr = document.createElement('div');
|
|
121
|
+
const isAnswered = m.kind === 'ask' ? isAskAnswered(m) : false;
|
|
122
|
+
corr.className = 'msg-corr ' + (m.kind === 'ask' ? (isAnswered ? 'answered' : 'pending') : '');
|
|
123
|
+
corr.textContent = m.correlationId.slice(0, 8);
|
|
124
|
+
div.appendChild(corr);
|
|
125
|
+
}
|
|
126
|
+
return div;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function escape(s) {
|
|
130
|
+
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
131
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
132
|
+
}[c]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function removeEmptyPlaceholder() {
|
|
136
|
+
const emp = els.feedBody.querySelector('.empty');
|
|
137
|
+
if (emp) emp.remove();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isNearBottom(el, slack = 150) {
|
|
141
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight < slack;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function scrollToBottom(el) {
|
|
145
|
+
el.scrollTop = el.scrollHeight;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function ingestMsg(m) {
|
|
149
|
+
if (!m || !m.id) return false;
|
|
150
|
+
if (state.messageIds.has(m.id)) return false;
|
|
151
|
+
state.messageIds.add(m.id);
|
|
152
|
+
state.messages.push(m);
|
|
153
|
+
state.msgCount++;
|
|
154
|
+
if (m.kind === 'reply' && m.correlationId && m.from) {
|
|
155
|
+
state.answeredByResponder.add(`${m.correlationId}\t${m.from}`);
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function appendMsg(m) {
|
|
161
|
+
if (!ingestMsg(m)) return; // dedup
|
|
162
|
+
if (m.kind === 'reply' && m.correlationId && m.from) {
|
|
163
|
+
const sel = `.msg.ask[data-corr="${cssEsc(m.correlationId)}"][data-ask-to="${cssEsc(m.from)}"] .msg-corr`;
|
|
164
|
+
const prev = els.feedBody.querySelector(sel);
|
|
165
|
+
if (prev) {
|
|
166
|
+
prev.className = 'msg-corr answered';
|
|
167
|
+
prev.textContent = m.correlationId.slice(0, 8);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const node = renderMsg(m);
|
|
171
|
+
if (node) {
|
|
172
|
+
const pinnedBottom = isNearBottom(els.feedBody);
|
|
173
|
+
removeEmptyPlaceholder();
|
|
174
|
+
els.feedBody.appendChild(node);
|
|
175
|
+
if (pinnedBottom) scrollToBottom(els.feedBody);
|
|
176
|
+
}
|
|
177
|
+
updateStats();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function ingestHistory(messages) {
|
|
181
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
182
|
+
let added = 0;
|
|
183
|
+
for (const m of messages) {
|
|
184
|
+
if (ingestMsg(m)) added++;
|
|
185
|
+
}
|
|
186
|
+
if (!added) return;
|
|
187
|
+
// Re-ordenar por ts (los históricos pueden llegar mezclados con el live).
|
|
188
|
+
state.messages.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
|
|
189
|
+
rerenderFeed();
|
|
190
|
+
updateStats();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function updateStats() {
|
|
194
|
+
els.msgCount.textContent = state.msgCount;
|
|
195
|
+
els.pairCount.textContent = state.messages.filter((x) => x.kind === 'reply').length;
|
|
196
|
+
let pending = 0;
|
|
197
|
+
for (const m of state.messages) {
|
|
198
|
+
if (m.kind === 'ask' && !isAskAnswered(m)) pending++;
|
|
199
|
+
}
|
|
200
|
+
els.pendingCount.textContent = pending;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderRooms() {
|
|
204
|
+
const names = Object.keys(state.rooms);
|
|
205
|
+
els.roomsCount.textContent = names.length;
|
|
206
|
+
els.roomsList.innerHTML = '';
|
|
207
|
+
|
|
208
|
+
const liAll = document.createElement('li');
|
|
209
|
+
liAll.className = 'all' + (state.selectedRoom === '*' ? ' active' : '');
|
|
210
|
+
liAll.innerHTML = `<div class="room-name">★ todas las salas</div><div class="members">${state.msgCount} mensajes totales</div>`;
|
|
211
|
+
liAll.addEventListener('click', () => selectRoom('*'));
|
|
212
|
+
els.roomsList.appendChild(liAll);
|
|
213
|
+
|
|
214
|
+
for (const name of names.sort()) {
|
|
215
|
+
const members = state.rooms[name] || [];
|
|
216
|
+
const li = document.createElement('li');
|
|
217
|
+
li.className = state.selectedRoom === name ? 'active' : '';
|
|
218
|
+
li.innerHTML = `<div class="room-name">${escape(name)}</div>` +
|
|
219
|
+
`<div class="members">${members.map((m) => `<span class="member">${escape(m)}</span>`).join('')}</div>`;
|
|
220
|
+
li.addEventListener('click', () => selectRoom(name));
|
|
221
|
+
els.roomsList.appendChild(li);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function selectRoom(room) {
|
|
226
|
+
state.selectedRoom = room;
|
|
227
|
+
els.feedTitle.textContent = room === '*' ? 'Todas las salas' : `Sala: ${room}`;
|
|
228
|
+
renderRooms();
|
|
229
|
+
rerenderFeed();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function rerenderFeed() {
|
|
233
|
+
els.feedBody.innerHTML = '';
|
|
234
|
+
let any = false;
|
|
235
|
+
for (const m of state.messages) {
|
|
236
|
+
const node = renderMsg(m);
|
|
237
|
+
if (node) { els.feedBody.appendChild(node); any = true; }
|
|
238
|
+
}
|
|
239
|
+
if (!any) {
|
|
240
|
+
const e = document.createElement('div');
|
|
241
|
+
e.className = 'empty';
|
|
242
|
+
e.textContent = state.selectedRoom === '*'
|
|
243
|
+
? 'Esperando mensajes...'
|
|
244
|
+
: `Sin mensajes en la sala "${state.selectedRoom}".`;
|
|
245
|
+
els.feedBody.appendChild(e);
|
|
246
|
+
}
|
|
247
|
+
// Tras un rerender (cambio de sala o ingest de history) siempre al último.
|
|
248
|
+
scrollToBottom(els.feedBody);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function wireFilters() {
|
|
252
|
+
Object.values(els.filters).forEach((el) => {
|
|
253
|
+
el.addEventListener('change', rerenderFeed);
|
|
254
|
+
});
|
|
255
|
+
els.clearBtn.addEventListener('click', () => {
|
|
256
|
+
state.messages = [];
|
|
257
|
+
state.messageIds.clear();
|
|
258
|
+
state.answeredByResponder.clear();
|
|
259
|
+
state.msgCount = 0;
|
|
260
|
+
rerenderFeed();
|
|
261
|
+
updateStats();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function pollStatus() {
|
|
266
|
+
if (!state.ws || state.ws.readyState !== 1) return;
|
|
267
|
+
state.ws.send(JSON.stringify({ op: 'status' }));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function connect() {
|
|
271
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
272
|
+
const url = `${proto}//${location.host}`;
|
|
273
|
+
setConn('', 'conectando...');
|
|
274
|
+
const ws = new WebSocket(url);
|
|
275
|
+
state.ws = ws;
|
|
276
|
+
|
|
277
|
+
ws.addEventListener('open', () => {
|
|
278
|
+
setConn('ok', 'en vivo');
|
|
279
|
+
// Suscribirse a todas las salas
|
|
280
|
+
ws.send(JSON.stringify({ op: 'watch', room: null }));
|
|
281
|
+
ws.send(JSON.stringify({ op: 'status' }));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
ws.addEventListener('message', (ev) => {
|
|
285
|
+
let data;
|
|
286
|
+
try { data = JSON.parse(ev.data); } catch (_) { return; }
|
|
287
|
+
if (data.type === 'msg') {
|
|
288
|
+
appendMsg(data);
|
|
289
|
+
} else if (data.type === 'system' && data.event === 'status') {
|
|
290
|
+
const detail = data.detail || {};
|
|
291
|
+
state.rooms = detail.rooms || {};
|
|
292
|
+
renderRooms();
|
|
293
|
+
// Pedir history de cada sala nueva detectada
|
|
294
|
+
for (const room of Object.keys(state.rooms)) {
|
|
295
|
+
if (!state.knownRooms.has(room)) {
|
|
296
|
+
state.knownRooms.add(room);
|
|
297
|
+
ws.send(JSON.stringify({ op: 'history', room, n: 100 }));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else if (data.type === 'history') {
|
|
301
|
+
ingestHistory(data.messages || []);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
ws.addEventListener('close', () => {
|
|
306
|
+
setConn('err', 'desconectado · reintentando...');
|
|
307
|
+
setTimeout(connect, 1500);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
ws.addEventListener('error', () => {
|
|
311
|
+
setConn('err', 'error de conexión');
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
wireFilters();
|
|
316
|
+
connect();
|
|
317
|
+
setInterval(pollStatus, 3000);
|
|
318
|
+
})();
|