@vdoninja/ninja-p2p 0.1.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 +138 -0
- package/dashboard.html +602 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/message-bus.d.ts +79 -0
- package/dist/message-bus.js +234 -0
- package/dist/peer-registry.d.ts +69 -0
- package/dist/peer-registry.js +200 -0
- package/dist/protocol.d.ts +57 -0
- package/dist/protocol.js +67 -0
- package/dist/vdo-bridge.d.ts +66 -0
- package/dist/vdo-bridge.js +318 -0
- package/package.json +78 -0
- package/skills/ninja-p2p/SKILL.md +74 -0
package/dashboard.html
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>NinjaClawd P2P Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
9
|
+
:root{
|
|
10
|
+
--bg:#0d1117;--bg2:#161b22;--bg3:#21262d;--border:#30363d;
|
|
11
|
+
--fg:#e6edf3;--fg2:#8b949e;--accent:#58a6ff;--green:#3fb950;
|
|
12
|
+
--red:#f85149;--yellow:#d29922;--purple:#bc8cff;
|
|
13
|
+
--font:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace;
|
|
14
|
+
}
|
|
15
|
+
body{background:var(--bg);color:var(--fg);font-family:var(--font);font-size:13px;line-height:1.5;display:flex;height:100vh;overflow:hidden}
|
|
16
|
+
a{color:var(--accent);text-decoration:none}
|
|
17
|
+
button{font-family:var(--font);cursor:pointer;font-size:12px;border:1px solid var(--border);background:var(--bg3);color:var(--fg);border-radius:6px;padding:4px 12px;transition:background .15s}
|
|
18
|
+
button:hover{background:var(--border)}
|
|
19
|
+
button.primary{background:var(--accent);color:#000;border-color:var(--accent)}
|
|
20
|
+
button.primary:hover{opacity:.85}
|
|
21
|
+
input,select{font-family:var(--font);font-size:12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:5px 8px;outline:none}
|
|
22
|
+
input:focus,select:focus{border-color:var(--accent)}
|
|
23
|
+
|
|
24
|
+
/* Layout */
|
|
25
|
+
#sidebar{width:240px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
|
26
|
+
#sidebar h1{font-size:14px;padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
|
|
27
|
+
#sidebar h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--red);flex-shrink:0}
|
|
28
|
+
#sidebar h1 .dot.on{background:var(--green)}
|
|
29
|
+
#main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
30
|
+
|
|
31
|
+
/* Connection Panel */
|
|
32
|
+
#conn-panel{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--bg2)}
|
|
33
|
+
#conn-panel label{display:block;font-size:11px;color:var(--fg2);margin-bottom:2px;margin-top:8px}
|
|
34
|
+
#conn-panel label:first-child{margin-top:0}
|
|
35
|
+
#conn-panel input{width:100%;margin-bottom:2px}
|
|
36
|
+
#conn-panel .row{display:flex;gap:6px;margin-top:10px}
|
|
37
|
+
#conn-panel .row button{flex:1}
|
|
38
|
+
#conn-panel .status{font-size:11px;color:var(--fg2);margin-top:6px;text-align:center}
|
|
39
|
+
|
|
40
|
+
/* Peer List */
|
|
41
|
+
#peer-section{flex:1;overflow-y:auto;padding:8px 0}
|
|
42
|
+
#peer-section h2{font-size:11px;color:var(--fg2);padding:4px 16px;text-transform:uppercase;letter-spacing:.5px}
|
|
43
|
+
.peer-item{padding:6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:background .1s}
|
|
44
|
+
.peer-item:hover{background:var(--bg3)}
|
|
45
|
+
.peer-item.selected{background:var(--bg3);border-left:2px solid var(--accent)}
|
|
46
|
+
.peer-item .indicator{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
|
47
|
+
.peer-item .indicator.online{background:var(--green)}
|
|
48
|
+
.peer-item .indicator.offline{background:var(--fg2)}
|
|
49
|
+
.peer-item .info{min-width:0}
|
|
50
|
+
.peer-item .name{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
51
|
+
.peer-item .role{font-size:10px;color:var(--fg2)}
|
|
52
|
+
|
|
53
|
+
/* Main content */
|
|
54
|
+
#messages{flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:2px}
|
|
55
|
+
.msg{padding:2px 0;word-break:break-word}
|
|
56
|
+
.msg .ts{color:var(--fg2);font-size:11px;margin-right:6px}
|
|
57
|
+
.msg .sender{font-weight:600;margin-right:4px}
|
|
58
|
+
.msg.type-chat .sender{color:var(--accent)}
|
|
59
|
+
.msg.type-announce .sender{color:var(--green)}
|
|
60
|
+
.msg.type-event .sender{color:var(--purple)}
|
|
61
|
+
.msg.type-command .sender{color:var(--yellow)}
|
|
62
|
+
.msg.type-command_response .sender{color:var(--yellow)}
|
|
63
|
+
.msg.type-system{color:var(--fg2);font-style:italic}
|
|
64
|
+
.msg.type-system .sender{color:var(--fg2)}
|
|
65
|
+
.msg .body{color:var(--fg)}
|
|
66
|
+
.msg .meta{color:var(--fg2);font-size:11px}
|
|
67
|
+
|
|
68
|
+
/* Chat Input */
|
|
69
|
+
#chat-bar{padding:8px 16px;border-top:1px solid var(--border);background:var(--bg2);display:flex;gap:6px;align-items:center}
|
|
70
|
+
#chat-bar input{flex:1}
|
|
71
|
+
#chat-bar select{width:auto;min-width:100px}
|
|
72
|
+
|
|
73
|
+
/* Skills panel */
|
|
74
|
+
#skills-panel{padding:8px 16px;border-top:1px solid var(--border);font-size:11px;color:var(--fg2);max-height:80px;overflow-y:auto}
|
|
75
|
+
.skill-tag{display:inline-block;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:1px 6px;margin:1px 2px;font-size:10px}
|
|
76
|
+
|
|
77
|
+
/* Responsive */
|
|
78
|
+
@media(max-width:600px){
|
|
79
|
+
#sidebar{width:100%;height:auto;max-height:40vh;border-right:none;border-bottom:1px solid var(--border)}
|
|
80
|
+
body{flex-direction:column}
|
|
81
|
+
}
|
|
82
|
+
</style>
|
|
83
|
+
</head>
|
|
84
|
+
<body>
|
|
85
|
+
|
|
86
|
+
<div id="sidebar">
|
|
87
|
+
<h1><span class="dot" id="conn-dot"></span> NinjaClawd</h1>
|
|
88
|
+
|
|
89
|
+
<div id="conn-panel">
|
|
90
|
+
<label>Room</label>
|
|
91
|
+
<input id="inp-room" placeholder="clawd_..." autocomplete="off">
|
|
92
|
+
<label>Password</label>
|
|
93
|
+
<input id="inp-password" placeholder="false = no encryption" autocomplete="off">
|
|
94
|
+
<label>Your Name</label>
|
|
95
|
+
<input id="inp-name" value="Dashboard" autocomplete="off">
|
|
96
|
+
<div class="row">
|
|
97
|
+
<button class="primary" id="btn-connect">Connect</button>
|
|
98
|
+
<button id="btn-disconnect" disabled>Disconnect</button>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="status" id="conn-status">Disconnected</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div id="peer-section">
|
|
104
|
+
<h2>Peers (<span id="peer-count">0</span>)</h2>
|
|
105
|
+
<div id="peer-list"></div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div id="skills-panel"></div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div id="main">
|
|
112
|
+
<div id="messages"></div>
|
|
113
|
+
<div id="chat-bar">
|
|
114
|
+
<select id="chat-target">
|
|
115
|
+
<option value="">Broadcast</option>
|
|
116
|
+
</select>
|
|
117
|
+
<input id="chat-input" placeholder="Type a message... (/help for commands)" autocomplete="off">
|
|
118
|
+
<button class="primary" id="btn-send">Send</button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<script src="https://cdn.jsdelivr.net/gh/steveseguin/ninjasdk@latest/vdoninja-sdk.min.js"></script>
|
|
123
|
+
<script>
|
|
124
|
+
(function() {
|
|
125
|
+
'use strict';
|
|
126
|
+
|
|
127
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
128
|
+
let sdk = null;
|
|
129
|
+
let connected = false;
|
|
130
|
+
let myStreamId = '';
|
|
131
|
+
let myIdentity = null;
|
|
132
|
+
const peers = new Map(); // streamId -> {uuid, identity, skills, status, connected}
|
|
133
|
+
const messages = [];
|
|
134
|
+
const MAX_MESSAGES = 500;
|
|
135
|
+
let selectedPeer = ''; // empty = broadcast
|
|
136
|
+
|
|
137
|
+
// ── DOM refs ───────────────────────────────────────────────────────────
|
|
138
|
+
const $ = (id) => document.getElementById(id);
|
|
139
|
+
const dot = $('conn-dot');
|
|
140
|
+
const statusEl = $('conn-status');
|
|
141
|
+
const peerCountEl = $('peer-count');
|
|
142
|
+
const peerListEl = $('peer-list');
|
|
143
|
+
const messagesEl = $('messages');
|
|
144
|
+
const chatInput = $('chat-input');
|
|
145
|
+
const chatTarget = $('chat-target');
|
|
146
|
+
const skillsPanel = $('skills-panel');
|
|
147
|
+
const btnConnect = $('btn-connect');
|
|
148
|
+
const btnDisconnect = $('btn-disconnect');
|
|
149
|
+
const btnSend = $('btn-send');
|
|
150
|
+
const inpRoom = $('inp-room');
|
|
151
|
+
const inpPassword = $('inp-password');
|
|
152
|
+
const inpName = $('inp-name');
|
|
153
|
+
|
|
154
|
+
// ── URL params ─────────────────────────────────────────────────────────
|
|
155
|
+
const params = new URLSearchParams(window.location.search);
|
|
156
|
+
if (params.get('room')) inpRoom.value = params.get('room');
|
|
157
|
+
if (params.get('password')) inpPassword.value = params.get('password');
|
|
158
|
+
if (params.get('name')) inpName.value = params.get('name');
|
|
159
|
+
|
|
160
|
+
// Restore from localStorage
|
|
161
|
+
if (!inpRoom.value) inpRoom.value = localStorage.getItem('clawd_room') || '';
|
|
162
|
+
if (!inpPassword.value) inpPassword.value = localStorage.getItem('clawd_password') || 'false';
|
|
163
|
+
if (!inpName.value || inpName.value === 'Dashboard') inpName.value = localStorage.getItem('clawd_name') || 'Dashboard';
|
|
164
|
+
|
|
165
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
166
|
+
function randomId() {
|
|
167
|
+
const arr = new Uint8Array(8);
|
|
168
|
+
crypto.getRandomValues(arr);
|
|
169
|
+
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function instanceId() {
|
|
173
|
+
return randomId().slice(0, 8);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fmtTime(ts) {
|
|
177
|
+
const d = new Date(ts);
|
|
178
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function escHtml(s) {
|
|
182
|
+
const d = document.createElement('div');
|
|
183
|
+
d.textContent = s;
|
|
184
|
+
return d.innerHTML;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Message Display ────────────────────────────────────────────────────
|
|
188
|
+
function addMessage(type, sender, body, ts, meta) {
|
|
189
|
+
const msg = { type, sender, body, ts: ts || Date.now(), meta };
|
|
190
|
+
messages.push(msg);
|
|
191
|
+
if (messages.length > MAX_MESSAGES) messages.shift();
|
|
192
|
+
renderMessage(msg);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderMessage(msg) {
|
|
196
|
+
const div = document.createElement('div');
|
|
197
|
+
div.className = 'msg type-' + msg.type;
|
|
198
|
+
let html = '<span class="ts">' + fmtTime(msg.ts) + '</span>';
|
|
199
|
+
html += '<span class="sender">' + escHtml(msg.sender) + '</span> ';
|
|
200
|
+
html += '<span class="body">' + escHtml(String(msg.body)) + '</span>';
|
|
201
|
+
if (msg.meta) html += ' <span class="meta">' + escHtml(msg.meta) + '</span>';
|
|
202
|
+
div.innerHTML = html;
|
|
203
|
+
messagesEl.appendChild(div);
|
|
204
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Peer List ──────────────────────────────────────────────────────────
|
|
208
|
+
function renderPeers() {
|
|
209
|
+
peerListEl.innerHTML = '';
|
|
210
|
+
peerCountEl.textContent = peers.size;
|
|
211
|
+
// Update chat target dropdown
|
|
212
|
+
const curVal = chatTarget.value;
|
|
213
|
+
chatTarget.innerHTML = '<option value="">Broadcast</option>';
|
|
214
|
+
|
|
215
|
+
for (const [sid, p] of peers) {
|
|
216
|
+
// Peer list item
|
|
217
|
+
const div = document.createElement('div');
|
|
218
|
+
div.className = 'peer-item' + (sid === selectedPeer ? ' selected' : '');
|
|
219
|
+
const name = p.identity?.name || sid;
|
|
220
|
+
const role = p.identity?.role || 'unknown';
|
|
221
|
+
div.innerHTML = `
|
|
222
|
+
<div class="indicator ${p.connected ? 'online' : 'offline'}"></div>
|
|
223
|
+
<div class="info">
|
|
224
|
+
<div class="name">${escHtml(name)}</div>
|
|
225
|
+
<div class="role">${escHtml(role)}${p.status && p.status !== 'online' ? ' - ' + escHtml(p.status) : ''}</div>
|
|
226
|
+
</div>`;
|
|
227
|
+
div.addEventListener('click', () => {
|
|
228
|
+
selectedPeer = selectedPeer === sid ? '' : sid;
|
|
229
|
+
chatTarget.value = selectedPeer;
|
|
230
|
+
renderPeers();
|
|
231
|
+
renderSkills();
|
|
232
|
+
});
|
|
233
|
+
peerListEl.appendChild(div);
|
|
234
|
+
|
|
235
|
+
// Chat target option
|
|
236
|
+
const opt = document.createElement('option');
|
|
237
|
+
opt.value = sid;
|
|
238
|
+
opt.textContent = name;
|
|
239
|
+
chatTarget.appendChild(opt);
|
|
240
|
+
}
|
|
241
|
+
chatTarget.value = curVal || '';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderSkills() {
|
|
245
|
+
if (!selectedPeer) {
|
|
246
|
+
skillsPanel.innerHTML = '';
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const p = peers.get(selectedPeer);
|
|
250
|
+
if (!p || !p.skills || p.skills.length === 0) {
|
|
251
|
+
skillsPanel.innerHTML = '<em>No skills announced</em>';
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
skillsPanel.innerHTML = '<strong>Skills:</strong> ' +
|
|
255
|
+
p.skills.map(s => '<span class="skill-tag">' + escHtml(s) + '</span>').join('');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Envelope Protocol ──────────────────────────────────────────────────
|
|
259
|
+
function createEnvelope(type, payload, opts) {
|
|
260
|
+
return {
|
|
261
|
+
v: 1,
|
|
262
|
+
id: randomId(),
|
|
263
|
+
type: type,
|
|
264
|
+
from: myIdentity,
|
|
265
|
+
to: (opts && opts.to) || null,
|
|
266
|
+
topic: (opts && opts.topic) || null,
|
|
267
|
+
ts: Date.now(),
|
|
268
|
+
payload: payload
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseEnvelope(data) {
|
|
273
|
+
try {
|
|
274
|
+
const obj = typeof data === 'string' ? JSON.parse(data) : data;
|
|
275
|
+
if (obj && obj.v === 1 && typeof obj.type === 'string' && obj.from) return obj;
|
|
276
|
+
return null;
|
|
277
|
+
} catch(e) { return null; }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Connection ─────────────────────────────────────────────────────────
|
|
281
|
+
async function doConnect() {
|
|
282
|
+
const room = inpRoom.value.trim();
|
|
283
|
+
if (!room) { alert('Room name is required'); return; }
|
|
284
|
+
|
|
285
|
+
const pw = inpPassword.value.trim();
|
|
286
|
+
const password = pw === 'false' ? false : (pw || undefined);
|
|
287
|
+
const name = inpName.value.trim() || 'Dashboard';
|
|
288
|
+
myStreamId = 'dashboard_' + randomId().slice(0, 8);
|
|
289
|
+
myIdentity = {
|
|
290
|
+
streamId: myStreamId,
|
|
291
|
+
role: 'dashboard',
|
|
292
|
+
name: name,
|
|
293
|
+
instanceId: instanceId()
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Save to localStorage
|
|
297
|
+
localStorage.setItem('clawd_room', room);
|
|
298
|
+
localStorage.setItem('clawd_password', inpPassword.value.trim());
|
|
299
|
+
localStorage.setItem('clawd_name', name);
|
|
300
|
+
|
|
301
|
+
statusEl.textContent = 'Connecting...';
|
|
302
|
+
btnConnect.disabled = true;
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
sdk = new VDONinjaSDK({
|
|
306
|
+
host: 'wss://wss.vdo.ninja',
|
|
307
|
+
debug: false
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Wire events
|
|
311
|
+
sdk.addEventListener('peerConnected', (e) => {
|
|
312
|
+
const uuid = e.detail?.uuid || 'unknown';
|
|
313
|
+
const sid = e.detail?.streamID || uuid;
|
|
314
|
+
if (!peers.has(sid)) {
|
|
315
|
+
peers.set(sid, { uuid, identity: null, skills: [], status: 'online', connected: true });
|
|
316
|
+
} else {
|
|
317
|
+
const p = peers.get(sid);
|
|
318
|
+
p.uuid = uuid;
|
|
319
|
+
p.connected = true;
|
|
320
|
+
}
|
|
321
|
+
renderPeers();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
sdk.addEventListener('dataChannelOpen', (e) => {
|
|
325
|
+
const uuid = e.detail?.uuid;
|
|
326
|
+
// Send our announce
|
|
327
|
+
const announce = createEnvelope('announce', {
|
|
328
|
+
skills: ['dashboard-ui', 'chat'],
|
|
329
|
+
status: 'online',
|
|
330
|
+
version: '0.1.0',
|
|
331
|
+
topics: ['events', 'bots', 'support']
|
|
332
|
+
});
|
|
333
|
+
try { sdk.sendData(announce, { UUID: uuid }); } catch(err) {}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
sdk.addEventListener('dataReceived', (e) => {
|
|
337
|
+
const raw = e.detail?.data;
|
|
338
|
+
const uuid = e.detail?.uuid || '';
|
|
339
|
+
const env = parseEnvelope(raw);
|
|
340
|
+
if (!env) return;
|
|
341
|
+
|
|
342
|
+
const sender = env.from.streamId;
|
|
343
|
+
// Update peer info
|
|
344
|
+
if (!peers.has(sender)) {
|
|
345
|
+
peers.set(sender, { uuid, identity: env.from, skills: [], status: 'online', connected: true });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
switch (env.type) {
|
|
349
|
+
case 'announce': {
|
|
350
|
+
const p = peers.get(sender);
|
|
351
|
+
if (p) {
|
|
352
|
+
p.identity = env.from;
|
|
353
|
+
p.skills = env.payload?.skills || [];
|
|
354
|
+
p.status = env.payload?.status || 'online';
|
|
355
|
+
}
|
|
356
|
+
addMessage('announce', env.from.name || sender,
|
|
357
|
+
`joined as ${env.from.role || '?'} with skills: ${(env.payload?.skills || []).join(', ')}`,
|
|
358
|
+
env.ts);
|
|
359
|
+
renderPeers();
|
|
360
|
+
renderSkills();
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
case 'skill_update': {
|
|
364
|
+
const p = peers.get(sender);
|
|
365
|
+
if (p) {
|
|
366
|
+
p.skills = env.payload?.skills || p.skills;
|
|
367
|
+
p.status = env.payload?.status || p.status;
|
|
368
|
+
}
|
|
369
|
+
addMessage('event', env.from.name || sender,
|
|
370
|
+
`status: ${env.payload?.status || '?'}${env.payload?.statusDetail ? ' - ' + env.payload.statusDetail : ''}`,
|
|
371
|
+
env.ts);
|
|
372
|
+
renderPeers();
|
|
373
|
+
renderSkills();
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case 'chat':
|
|
377
|
+
addMessage('chat', env.from.name || sender,
|
|
378
|
+
env.payload?.text || env.payload?.message || JSON.stringify(env.payload),
|
|
379
|
+
env.ts,
|
|
380
|
+
env.topic ? '[' + env.topic + ']' : undefined);
|
|
381
|
+
break;
|
|
382
|
+
case 'event':
|
|
383
|
+
addMessage('event', env.from.name || sender,
|
|
384
|
+
env.payload?.kind ? env.payload.kind + ': ' + JSON.stringify(env.payload) : JSON.stringify(env.payload),
|
|
385
|
+
env.ts,
|
|
386
|
+
env.topic ? '[' + env.topic + ']' : undefined);
|
|
387
|
+
break;
|
|
388
|
+
case 'command':
|
|
389
|
+
addMessage('command', env.from.name || sender,
|
|
390
|
+
'CMD: ' + (env.payload?.command || '?') + ' ' + JSON.stringify(env.payload?.args || {}),
|
|
391
|
+
env.ts);
|
|
392
|
+
break;
|
|
393
|
+
case 'command_response':
|
|
394
|
+
addMessage('command_response', env.from.name || sender,
|
|
395
|
+
JSON.stringify(env.payload, null, 2),
|
|
396
|
+
env.ts);
|
|
397
|
+
break;
|
|
398
|
+
case 'ping':
|
|
399
|
+
// Auto-respond with pong
|
|
400
|
+
try {
|
|
401
|
+
const pong = createEnvelope('pong', { pingId: env.id, pongTs: Date.now() }, { to: sender });
|
|
402
|
+
sdk.sendData(pong, { UUID: uuid });
|
|
403
|
+
} catch(err) {}
|
|
404
|
+
break;
|
|
405
|
+
case 'history_replay': {
|
|
406
|
+
// Replay contains an original message as payload
|
|
407
|
+
const orig = env.payload;
|
|
408
|
+
if (orig && orig.type) {
|
|
409
|
+
addMessage(orig.type, (orig.from?.name || 'replay'),
|
|
410
|
+
orig.payload?.text || orig.payload?.message || JSON.stringify(orig.payload),
|
|
411
|
+
orig.ts,
|
|
412
|
+
'[replay]');
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
default:
|
|
417
|
+
addMessage(env.type, env.from.name || sender,
|
|
418
|
+
JSON.stringify(env.payload),
|
|
419
|
+
env.ts);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
sdk.addEventListener('peerDisconnected', (e) => {
|
|
424
|
+
const uuid = e.detail?.uuid || '';
|
|
425
|
+
const sid = e.detail?.streamID || '';
|
|
426
|
+
// Find by uuid if no streamID
|
|
427
|
+
let key = sid;
|
|
428
|
+
if (!key) {
|
|
429
|
+
for (const [k, v] of peers) { if (v.uuid === uuid) { key = k; break; } }
|
|
430
|
+
}
|
|
431
|
+
if (key && peers.has(key)) {
|
|
432
|
+
const p = peers.get(key);
|
|
433
|
+
const name = p.identity?.name || key;
|
|
434
|
+
p.connected = false;
|
|
435
|
+
addMessage('system', name, 'disconnected', Date.now());
|
|
436
|
+
renderPeers();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
sdk.addEventListener('listing', (e) => {
|
|
441
|
+
const list = e.detail?.list || [];
|
|
442
|
+
for (const entry of list) {
|
|
443
|
+
if (entry.streamID && entry.streamID !== myStreamId) {
|
|
444
|
+
sdk.view(entry.streamID, { audio: false, video: false });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
sdk.addEventListener('disconnected', () => {
|
|
450
|
+
statusEl.textContent = 'WebSocket lost, reconnecting...';
|
|
451
|
+
dot.classList.remove('on');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
sdk.addEventListener('reconnected', () => {
|
|
455
|
+
statusEl.textContent = 'Reconnected';
|
|
456
|
+
dot.classList.add('on');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Connect
|
|
460
|
+
await sdk.connect();
|
|
461
|
+
const joinOpts = { room: room };
|
|
462
|
+
if (password !== undefined) joinOpts.password = password;
|
|
463
|
+
await sdk.joinRoom(joinOpts);
|
|
464
|
+
await sdk.announce({ streamID: myStreamId });
|
|
465
|
+
|
|
466
|
+
connected = true;
|
|
467
|
+
dot.classList.add('on');
|
|
468
|
+
statusEl.textContent = 'Connected as ' + myStreamId;
|
|
469
|
+
btnConnect.disabled = true;
|
|
470
|
+
btnDisconnect.disabled = false;
|
|
471
|
+
inpRoom.disabled = true;
|
|
472
|
+
inpPassword.disabled = true;
|
|
473
|
+
|
|
474
|
+
addMessage('system', 'System', 'Connected to room: ' + room, Date.now());
|
|
475
|
+
|
|
476
|
+
// Request history from any peers
|
|
477
|
+
setTimeout(() => {
|
|
478
|
+
if (sdk && connected) {
|
|
479
|
+
const req = createEnvelope('history_request', { count: 50 });
|
|
480
|
+
try { sdk.sendData(req); } catch(err) {}
|
|
481
|
+
}
|
|
482
|
+
}, 2000);
|
|
483
|
+
|
|
484
|
+
} catch(err) {
|
|
485
|
+
statusEl.textContent = 'Error: ' + err.message;
|
|
486
|
+
btnConnect.disabled = false;
|
|
487
|
+
console.error('Connection error:', err);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function doDisconnect() {
|
|
492
|
+
if (sdk) {
|
|
493
|
+
try { sdk.leaveRoom(); } catch(e) {}
|
|
494
|
+
try { sdk.disconnect(); } catch(e) {}
|
|
495
|
+
}
|
|
496
|
+
sdk = null;
|
|
497
|
+
connected = false;
|
|
498
|
+
peers.clear();
|
|
499
|
+
dot.classList.remove('on');
|
|
500
|
+
statusEl.textContent = 'Disconnected';
|
|
501
|
+
btnConnect.disabled = false;
|
|
502
|
+
btnDisconnect.disabled = true;
|
|
503
|
+
inpRoom.disabled = false;
|
|
504
|
+
inpPassword.disabled = false;
|
|
505
|
+
renderPeers();
|
|
506
|
+
addMessage('system', 'System', 'Disconnected', Date.now());
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Chat Commands ──────────────────────────────────────────────────────
|
|
510
|
+
function handleInput() {
|
|
511
|
+
const text = chatInput.value.trim();
|
|
512
|
+
if (!text) return;
|
|
513
|
+
chatInput.value = '';
|
|
514
|
+
|
|
515
|
+
// Local commands
|
|
516
|
+
if (text.startsWith('/')) {
|
|
517
|
+
const parts = text.slice(1).split(' ');
|
|
518
|
+
const cmd = parts[0].toLowerCase();
|
|
519
|
+
switch (cmd) {
|
|
520
|
+
case 'help':
|
|
521
|
+
addMessage('system', 'System',
|
|
522
|
+
'Commands: /help, /peers, /status, /history [n], /health, /clear, /dm <peer> <msg>',
|
|
523
|
+
Date.now());
|
|
524
|
+
return;
|
|
525
|
+
case 'peers':
|
|
526
|
+
if (!connected) { addMessage('system', 'System', 'Not connected', Date.now()); return; }
|
|
527
|
+
// Send command to all bots
|
|
528
|
+
sendEnvelope('command', { command: 'peers' });
|
|
529
|
+
return;
|
|
530
|
+
case 'status':
|
|
531
|
+
if (!connected) { addMessage('system', 'System', 'Not connected', Date.now()); return; }
|
|
532
|
+
sendEnvelope('command', { command: 'status' });
|
|
533
|
+
return;
|
|
534
|
+
case 'history':
|
|
535
|
+
if (!connected) { addMessage('system', 'System', 'Not connected', Date.now()); return; }
|
|
536
|
+
sendEnvelope('command', { command: 'history', args: { count: parseInt(parts[1]) || 50 } });
|
|
537
|
+
return;
|
|
538
|
+
case 'health':
|
|
539
|
+
if (!connected) { addMessage('system', 'System', 'Not connected', Date.now()); return; }
|
|
540
|
+
sendEnvelope('command', { command: 'health' });
|
|
541
|
+
return;
|
|
542
|
+
case 'clear':
|
|
543
|
+
messagesEl.innerHTML = '';
|
|
544
|
+
messages.length = 0;
|
|
545
|
+
return;
|
|
546
|
+
case 'dm':
|
|
547
|
+
if (parts.length < 3) { addMessage('system', 'System', 'Usage: /dm <peer_stream_id> <message>', Date.now()); return; }
|
|
548
|
+
const target = parts[1];
|
|
549
|
+
const msg = parts.slice(2).join(' ');
|
|
550
|
+
sendEnvelope('chat', { text: msg }, { to: target });
|
|
551
|
+
addMessage('chat', myIdentity.name + ' -> ' + target, msg, Date.now());
|
|
552
|
+
return;
|
|
553
|
+
default:
|
|
554
|
+
addMessage('system', 'System', 'Unknown command: /' + cmd + '. Try /help', Date.now());
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Regular chat message
|
|
560
|
+
if (!connected) { addMessage('system', 'System', 'Not connected', Date.now()); return; }
|
|
561
|
+
const target = chatTarget.value || null;
|
|
562
|
+
sendEnvelope('chat', { text: text }, target ? { to: target } : undefined);
|
|
563
|
+
addMessage('chat', myIdentity.name + (target ? ' -> ' + target : ''), text, Date.now());
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function sendEnvelope(type, payload, opts) {
|
|
567
|
+
if (!sdk || !connected) return;
|
|
568
|
+
const env = createEnvelope(type, payload, opts);
|
|
569
|
+
try {
|
|
570
|
+
if (opts && opts.to) {
|
|
571
|
+
// Find the peer's UUID for targeted send
|
|
572
|
+
const p = peers.get(opts.to);
|
|
573
|
+
if (p) {
|
|
574
|
+
sdk.sendData(env, { UUID: p.uuid });
|
|
575
|
+
} else {
|
|
576
|
+
sdk.sendData(env, { streamID: opts.to });
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
sdk.sendData(env);
|
|
580
|
+
}
|
|
581
|
+
} catch(err) {
|
|
582
|
+
addMessage('system', 'System', 'Send error: ' + err.message, Date.now());
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── Event Bindings ─────────────────────────────────────────────────────
|
|
587
|
+
btnConnect.addEventListener('click', doConnect);
|
|
588
|
+
btnDisconnect.addEventListener('click', doDisconnect);
|
|
589
|
+
btnSend.addEventListener('click', handleInput);
|
|
590
|
+
chatInput.addEventListener('keydown', (e) => {
|
|
591
|
+
if (e.key === 'Enter') { e.preventDefault(); handleInput(); }
|
|
592
|
+
});
|
|
593
|
+
chatTarget.addEventListener('change', () => { selectedPeer = chatTarget.value; renderPeers(); });
|
|
594
|
+
|
|
595
|
+
// Auto-connect if room is provided via URL params
|
|
596
|
+
if (params.get('room') && params.get('autoconnect') !== 'false') {
|
|
597
|
+
setTimeout(doConnect, 500);
|
|
598
|
+
}
|
|
599
|
+
})();
|
|
600
|
+
</script>
|
|
601
|
+
</body>
|
|
602
|
+
</html>
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { MessageBus, type KeywordTrigger, type MessageBusOptions, type SendDataFn } from "./message-bus.js";
|
|
2
|
+
export { PeerRegistry, type PeerRecord, type PeerRegistryEvents } from "./peer-registry.js";
|
|
3
|
+
export { createEnvelope, createInstanceId, createMessageId, envelopeToWire, generateRoomName, isValidEnvelope, parseEnvelope, type AnnouncePayload, type MessageEnvelope, type MessageType, type PeerIdentity, type SkillUpdatePayload, } from "./protocol.js";
|
|
4
|
+
export { VDOBridge, type VDOBridgeOptions } from "./vdo-bridge.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { MessageBus } from "./message-bus.js";
|
|
2
|
+
export { PeerRegistry } from "./peer-registry.js";
|
|
3
|
+
export { createEnvelope, createInstanceId, createMessageId, envelopeToWire, generateRoomName, isValidEnvelope, parseEnvelope, } from "./protocol.js";
|
|
4
|
+
export { VDOBridge } from "./vdo-bridge.js";
|