agentchannel 0.7.19 → 0.7.20

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/ui/app.js ADDED
@@ -0,0 +1,836 @@
1
+ // AgentChannel Frontend — works in both Web UI (HTTP) and Tauri (WebView) modes
2
+ // CONFIG is injected by the server (web mode) or Tauri (desktop mode)
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // API Adapter Layer — auto-detect environment
6
+ // ---------------------------------------------------------------------------
7
+ const isTauri = !!window.__TAURI__;
8
+
9
+ const API = isTauri ? {
10
+ invoke: window.__TAURI__.core.invoke,
11
+ listen: window.__TAURI__.event.listen,
12
+ } : {
13
+ invoke: async function(cmd, args) {
14
+ if (cmd === 'get_config') return fetch('/api/config').then(function(r) { return r.json(); });
15
+ if (cmd === 'read_messages') return fetch('/api/messages?' + new URLSearchParams(args || {})).then(function(r) { return r.json(); });
16
+ if (cmd === 'get_identity') return fetch('/api/identity').then(function(r) { return r.json(); });
17
+ if (cmd === 'get_members') return fetch('/api/members?' + new URLSearchParams(args || {})).then(function(r) { return r.json(); });
18
+ if (cmd === 'send_message') return fetch('/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }).then(function(r) { return r.json(); });
19
+ throw new Error('Unknown command: ' + cmd);
20
+ },
21
+ listen: async function(event, callback) {
22
+ // Web UI mode: real-time via MQTT WebSocket (handled in init)
23
+ },
24
+ };
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // State
28
+ // ---------------------------------------------------------------------------
29
+ // CONFIG is set on window by the server-injected script tag or Tauri
30
+ var CONFIG = window.__AC_CONFIG__ || {};
31
+
32
+ var COLORS = ["#7c8a9a","#8b7e74","#6e8a7a","#8a7e8e","#7a8a8e","#8e857a","#7a7e8e","#7e8a7a"];
33
+ var senderColors = {};
34
+ var activeChannel = "all";
35
+ var allMessages = [];
36
+ var unreadCounts = {};
37
+ var collapsedGroups = {};
38
+ var onlineMembers = {}; // channel -> Set of names
39
+ var channelMetas = {}; // channel name -> meta object
40
+
41
+ var encoder = new TextEncoder();
42
+ var decoder = new TextDecoder();
43
+
44
+ function getColor(name) {
45
+ if (!senderColors[name]) senderColors[name] = COLORS[Object.keys(senderColors).length % COLORS.length];
46
+ return senderColors[name];
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // ACP-1: HKDF-based key derivation (Web Crypto)
51
+ // ---------------------------------------------------------------------------
52
+ async function hkdfExtract(ikm) {
53
+ var key = await crypto.subtle.importKey("raw", encoder.encode("acp1:extract"), {name:"HMAC",hash:"SHA-256"}, false, ["sign"]);
54
+ var prk = await crypto.subtle.sign("HMAC", key, encoder.encode(ikm));
55
+ return new Uint8Array(prk);
56
+ }
57
+
58
+ async function hkdfExpand(prk, info, length) {
59
+ var key = await crypto.subtle.importKey("raw", prk, {name:"HMAC",hash:"SHA-256"}, false, ["sign"]);
60
+ // Single iteration HKDF-Expand (length <= 32)
61
+ var input = new Uint8Array([...encoder.encode(info), 1]);
62
+ var okm = await crypto.subtle.sign("HMAC", key, input);
63
+ return new Uint8Array(okm).slice(0, length);
64
+ }
65
+
66
+ async function deriveKey(s) {
67
+ var prk = await hkdfExtract(s);
68
+ var keyBytes = await hkdfExpand(prk, "acp1:enc:channel:epoch:0", 32);
69
+ return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
70
+ }
71
+
72
+ async function deriveSubKeyWeb(channelKey, subName) {
73
+ var prk = await hkdfExtract(channelKey);
74
+ var keyBytes = await hkdfExpand(prk, "acp1:enc:sub:" + subName + ":epoch:0", 32);
75
+ return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
76
+ }
77
+
78
+ async function hashRoom(c) {
79
+ var prk = await hkdfExtract(c);
80
+ var topicBytes = await hkdfExpand(prk, "acp1:topic:channel", 16);
81
+ return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
82
+ }
83
+
84
+ async function hashSubWeb(channelKey, subName) {
85
+ var prk = await hkdfExtract(channelKey);
86
+ var topicBytes = await hkdfExpand(prk, "acp1:topic:sub:" + subName, 16);
87
+ return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
88
+ }
89
+
90
+ async function decryptPayload(payload, key) {
91
+ var p = JSON.parse(payload);
92
+ var iv = Uint8Array.from(atob(p.iv), function(c) { return c.charCodeAt(0); });
93
+ var data = Uint8Array.from(atob(p.data), function(c) { return c.charCodeAt(0); });
94
+ var tag = Uint8Array.from(atob(p.tag), function(c) { return c.charCodeAt(0); });
95
+ var combined = new Uint8Array(data.length + tag.length);
96
+ combined.set(data);
97
+ combined.set(tag, data.length);
98
+ return decoder.decode(await crypto.subtle.decrypt({name:"AES-GCM",iv:iv}, key, combined));
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // DOM references
103
+ // ---------------------------------------------------------------------------
104
+ var msgsEl = document.getElementById("messages");
105
+ var scrollEl = document.getElementById("messages-scroll");
106
+ var headerName = document.getElementById("header-name");
107
+ var headerDesc = document.getElementById("header-desc");
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Utility functions
111
+ // ---------------------------------------------------------------------------
112
+ function esc(s) {
113
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
114
+ }
115
+
116
+ function chId(ch) {
117
+ return ch.subchannel ? ch.channel + '/' + ch.subchannel : ch.channel;
118
+ }
119
+
120
+ function chLabel(ch) {
121
+ return ch.subchannel ? '##' + ch.subchannel : '#' + ch.channel;
122
+ }
123
+
124
+ function chFullLabel(ch) {
125
+ return ch.subchannel ? '#' + ch.channel + ' ##' + ch.subchannel : '#' + ch.channel;
126
+ }
127
+
128
+ var INLINE_TAG_COLORS = {
129
+ bug:'239,68,68', p0:'239,68,68', p1:'245,158,11', p2:'107,114,128',
130
+ feature:'59,130,246', release:'34,197,94', security:'168,85,247',
131
+ design:'236,72,153', docs:'99,102,241', protocol:'139,92,246',
132
+ todo:'245,158,11', fix:'239,68,68'
133
+ };
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Rich text rendering (markdown + @mentions + #channels + ##subchannels)
137
+ // ---------------------------------------------------------------------------
138
+ function richText(t) {
139
+ // Let marked parse markdown (preserves code blocks with <pre><code>)
140
+ var s = marked.parse(t, {breaks: true});
141
+
142
+ // Known channels and subchannels — use string split/join to avoid regex issues
143
+ var knownChannels = CONFIG.channels.filter(function(c) { return !c.subchannel; }).map(function(c) { return c.channel; });
144
+ var knownSubs = CONFIG.channels.filter(function(c) { return c.subchannel; }).map(function(c) { return c.subchannel; });
145
+
146
+ // Replace ##subchannel references (do subs first to avoid # matching partial ##)
147
+ for (var ki = 0; ki < knownSubs.length; ki++) {
148
+ s = s.split('##' + knownSubs[ki]).join(
149
+ '<span class="channel-tag" onclick="window.switchToSub(\'' + knownSubs[ki] + '\')">##' + knownSubs[ki] + '</span>'
150
+ );
151
+ }
152
+ // Replace #channel references
153
+ for (var ki = 0; ki < knownChannels.length; ki++) {
154
+ s = s.split('#' + knownChannels[ki]).join(
155
+ '<span class="channel-tag" onclick="window.switchToChannel(\'' + knownChannels[ki] + '\')">#' + knownChannels[ki] + '</span>'
156
+ );
157
+ }
158
+
159
+ // @mentions
160
+ var mentionRe = /@([a-zA-Z0-9_]+)/g;
161
+ s = s.replace(mentionRe, '<span class="mention">@$1</span>');
162
+
163
+ // Add copy button to code blocks
164
+ s = s.replace(/<pre>/g, '<pre><button class="copy-btn" onclick="window.copyCode(this)">copy</button>');
165
+
166
+ return s;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Render messages
171
+ // ---------------------------------------------------------------------------
172
+ function render() {
173
+ var filtered = activeChannel === "all"
174
+ ? allMessages.slice()
175
+ : allMessages.filter(function(m) {
176
+ var mid = m.subchannel ? m.channel + '/' + m.subchannel : m.channel;
177
+ return mid === activeChannel;
178
+ });
179
+
180
+ // Insert readme as first message (never mutate allMessages)
181
+ if (activeChannel !== "all") {
182
+ var parts = activeChannel.split("/");
183
+ var chName = parts[0];
184
+ var subName = parts[1];
185
+ var meta = channelMetas[chName];
186
+ var readme = meta && meta.readme && !subName ? meta.readme : null;
187
+ if (readme) {
188
+ var ownerFps = meta.owners ? meta.owners.map(function(fp) {
189
+ var found = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.fingerprint === fp; });
190
+ return found ? found.name + '(' + fp.slice(0, 4) + ')' : fp.slice(0, 4);
191
+ }).join(", ") : "";
192
+ filtered = [{id:"readme", channel:chName, sender:"readme", content:readme, timestamp:0, type:"readme", ownerFps:ownerFps}].concat(filtered);
193
+ }
194
+ }
195
+
196
+ if (!filtered.length) {
197
+ msgsEl.innerHTML = '<div class="empty">No messages yet</div>';
198
+ return;
199
+ }
200
+
201
+ var html = "";
202
+ var lastSender = null;
203
+ var lastChannel = null;
204
+
205
+ for (var i = 0; i < filtered.length; i++) {
206
+ var msg = filtered[i];
207
+
208
+ if (msg.type === "readme") {
209
+ html += '<div class="readme-card" style="border:1px solid var(--border);border-radius:10px;padding:20px 24px;margin-bottom:20px;font-size:0.85rem;line-height:1.6;color:var(--text-secondary);background:var(--bg)">'
210
+ + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">'
211
+ + '<span style="background:rgba(99,102,241,0.1);color:rgb(99,102,241);font-size:0.6rem;padding:2px 6px;border-radius:3px;font-weight:600">README</span>'
212
+ + '<span style="font-size:0.65rem;color:var(--text-muted)">owner: ' + (msg.ownerFps || '') + '</span>'
213
+ + '</div>' + richText(msg.content) + '</div>';
214
+ lastSender = null;
215
+ continue;
216
+ }
217
+
218
+ if (msg.type === "system") {
219
+ html += '<div class="system-msg">' + esc(msg.content) + '</div>';
220
+ lastSender = null;
221
+ continue;
222
+ }
223
+
224
+ var time = new Date(msg.timestamp).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"});
225
+ var color = getColor(msg.sender);
226
+ var isGrouped = lastSender === msg.sender && lastChannel === msg.channel;
227
+ var isMention = msg.content && msg.content.indexOf('@' + CONFIG.name) !== -1;
228
+
229
+ if (!isGrouped) {
230
+ if (lastSender !== null) html += '</div>'; // close previous conversation
231
+ html += '<div class="conversation"' + (isMention ? ' style="background:var(--mention-bg);border-left:3px solid var(--mention-text);padding-left:12px;margin-left:-15px;border-radius:4px"' : '') + '>';
232
+ html += '<div class="conversation__label">';
233
+ var msgFp = msg.senderKey ? '(' + msg.senderKey.slice(0, 4) + ')' : '';
234
+ html += '<span class="conversation__sender">' + esc(msg.sender) + '<span style="color:var(--text-muted);font-weight:400;font-size:0.65rem;margin-left:2px">' + msgFp + '</span></span>';
235
+ if (activeChannel === "all") {
236
+ var mlabel = msg.subchannel ? '#' + esc(msg.channel) + ' ##' + esc(msg.subchannel) : '#' + esc(msg.channel);
237
+ html += '<span class="conversation__channel">' + mlabel + '</span>';
238
+ }
239
+ html += '<span class="conversation__time">' + time + '</span>';
240
+ html += '</div>';
241
+ html += '<button class="msg-copy" onclick="window.copyMsg(this)" data-msg="' + esc(msg.content).replace(/"/g, '&quot;') + '" title="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
242
+ }
243
+
244
+ html += '<div class="conversation__text' + (isGrouped ? ' conversation__text--grouped' : '') + '">' + richText(msg.content) + '</div>';
245
+
246
+ lastSender = msg.sender;
247
+ lastChannel = msg.channel;
248
+ }
249
+ if (lastSender !== null) html += '</div>'; // close last conversation
250
+
251
+ msgsEl.innerHTML = html;
252
+ scrollEl.scrollTop = scrollEl.scrollHeight;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Render sidebar
257
+ // ---------------------------------------------------------------------------
258
+ function renderSidebar() {
259
+ var el = document.getElementById("channel-list");
260
+ el.innerHTML = "";
261
+ var lockIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
262
+ var globeIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
263
+
264
+ // Sort channels alphabetically, group subchannels under parent
265
+ var sorted = CONFIG.channels.slice().sort(function(a, b) { return chId(a).localeCompare(chId(b)); });
266
+ var OFFICIAL = "agentchannel";
267
+
268
+ // Build parent->children map using subchannel field
269
+ var parents = [];
270
+ var childrenMap = {};
271
+ for (var i = 0; i < sorted.length; i++) {
272
+ var ch = sorted[i];
273
+ if (ch.subchannel) {
274
+ if (!childrenMap[ch.channel]) childrenMap[ch.channel] = [];
275
+ childrenMap[ch.channel].push(ch);
276
+ } else {
277
+ parents.push(ch);
278
+ }
279
+ }
280
+
281
+ // Render All channels
282
+ var allDiv = document.createElement("div");
283
+ allDiv.className = "sidebar__channel" + (activeChannel === "all" ? " active" : "");
284
+ var allCount = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
285
+ allDiv.innerHTML = '<span class="icon"></span>#All channels' + (allCount ? '<span class="badge">' + allCount + '</span>' : "");
286
+ allDiv.onclick = function() {
287
+ activeChannel = "all";
288
+ for (var k in unreadCounts) unreadCounts[k] = 0;
289
+ headerName.textContent = "# All";
290
+ headerDesc.textContent = "All channels";
291
+ document.title = "AgentChannel";
292
+ history.pushState(null, "", "/");
293
+ renderSidebar();
294
+ render();
295
+ if (window.renderMembers) window.renderMembers();
296
+ };
297
+ el.appendChild(allDiv);
298
+
299
+ // Render each parent + children
300
+ for (var pi = 0; pi < parents.length; pi++) {
301
+ var ch = parents[pi];
302
+ var isOfficial = ch.channel.toLowerCase() === OFFICIAL;
303
+ var statusIcon = isOfficial ? globeIcon : lockIcon;
304
+ var hasChildren = childrenMap[ch.channel] && childrenMap[ch.channel].length > 0;
305
+ var collapsed = collapsedGroups[ch.channel] || false;
306
+
307
+ var div = document.createElement("div");
308
+ var cid = chId(ch);
309
+ div.className = "sidebar__channel" + (activeChannel === cid ? " active" : "");
310
+ var count = unreadCounts[cid] || 0;
311
+ var chInfo = (window.acChannels || {})[cid];
312
+ var chHash = chInfo ? chInfo.hash : '';
313
+ var chTail = chHash ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:3px;opacity:0.8">(' + chHash.slice(0, 4) + ')</span>' : '';
314
+ div.innerHTML = '<span class="icon"></span>#' + esc(ch.channel) + chTail + '<span style="opacity:0.5;margin-left:4px;display:inline-flex">' + statusIcon + '</span>' + (count ? '<span class="badge">' + count + '</span>' : "");
315
+
316
+ if (hasChildren) {
317
+ var arrowBtn = document.createElement("span");
318
+ arrowBtn.style.cssText = "font-size:0.55rem;margin-left:auto;opacity:0.4;padding:2px 4px;cursor:pointer";
319
+ arrowBtn.textContent = collapsed ? "\u25B6" : "\u25BC";
320
+ (function(chName, wasCollapsed) {
321
+ arrowBtn.onclick = function(e) {
322
+ e.stopPropagation();
323
+ collapsedGroups[chName] = !wasCollapsed;
324
+ renderSidebar();
325
+ };
326
+ })(ch.channel, collapsed);
327
+ div.appendChild(arrowBtn);
328
+ }
329
+
330
+ (function(chObj, channelId) {
331
+ div.onclick = function() {
332
+ activeChannel = channelId;
333
+ unreadCounts[channelId] = 0;
334
+ headerName.textContent = "#" + chObj.channel;
335
+ headerDesc.textContent = (channelMetas[chObj.channel] && channelMetas[chObj.channel].description) || "";
336
+ document.title = "AgentChannel";
337
+ history.pushState(null, "", "/channel/" + encodeURIComponent(chObj.channel));
338
+ renderSidebar();
339
+ render();
340
+ if (window.renderMembers) window.renderMembers();
341
+ };
342
+ })(ch, cid);
343
+ el.appendChild(div);
344
+
345
+ // Render children if not collapsed
346
+ if (hasChildren && !collapsed) {
347
+ var children = childrenMap[ch.channel];
348
+ for (var ci = 0; ci < children.length; ci++) {
349
+ var sub = children[ci];
350
+ var subCid = chId(sub);
351
+ var subDiv = document.createElement("div");
352
+ subDiv.className = "sidebar__channel sub" + (activeChannel === subCid ? " active" : "");
353
+ var subCount = unreadCounts[subCid] || 0;
354
+ subDiv.innerHTML = '<span class="icon"></span>##' + esc(sub.subchannel) + (subCount ? '<span class="badge">' + subCount + '</span>' : "");
355
+ (function(subObj, parentChannel, subChannelId) {
356
+ subDiv.onclick = function() {
357
+ activeChannel = subChannelId;
358
+ unreadCounts[subChannelId] = 0;
359
+ headerName.textContent = "##" + subObj.subchannel;
360
+ var subDesc = (channelMetas[parentChannel] && channelMetas[parentChannel].descriptions && channelMetas[parentChannel].descriptions[subObj.subchannel]) || "";
361
+ headerDesc.textContent = "#" + parentChannel + (subDesc ? " \u00B7 " + subDesc : "");
362
+ document.title = "AgentChannel";
363
+ history.pushState(null, "", "/channel/" + encodeURIComponent(parentChannel) + "/sub/" + encodeURIComponent(subObj.subchannel));
364
+ renderSidebar();
365
+ render();
366
+ if (window.renderMembers) window.renderMembers();
367
+ };
368
+ })(sub, ch.channel, subCid);
369
+ el.appendChild(subDiv);
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Channel actions
377
+ // ---------------------------------------------------------------------------
378
+ window.shareChannel = function() {
379
+ var ch = CONFIG.channels.find(function(c) { return chId(c) === activeChannel; });
380
+ if (!ch) return;
381
+ fetch("https://api.agentchannel.workers.dev/invites", {
382
+ method: "POST",
383
+ headers: {"Content-Type": "application/json"},
384
+ body: JSON.stringify({
385
+ channel: ch.channel,
386
+ key: ch.key,
387
+ subchannel: ch.subchannel || undefined,
388
+ created_by: CONFIG.fingerprint || CONFIG.name,
389
+ public: true
390
+ })
391
+ }).then(function(res) { return res.json(); }).then(function(data) {
392
+ if (data.token) {
393
+ var link = "https://agentchannel.io/join#token=" + data.token + "&name=" + encodeURIComponent(ch.channel);
394
+ navigator.clipboard.writeText(link);
395
+ alert("Invite link copied! (expires in 24h)\n\n" + link);
396
+ } else {
397
+ alert("Failed to create invite");
398
+ }
399
+ }).catch(function() {
400
+ alert("Failed to create invite");
401
+ });
402
+ };
403
+
404
+ window.leaveChannel = function() {
405
+ if (!confirm("Leave #" + activeChannel + "?")) return;
406
+ // Remove from config display (actual config change needs CLI)
407
+ CONFIG.channels = CONFIG.channels.filter(function(c) { return c.channel !== activeChannel; });
408
+ activeChannel = "all";
409
+ headerName.textContent = "# All channels";
410
+ headerDesc.textContent = "All channels";
411
+ renderSidebar();
412
+ render();
413
+ if (window.renderMembers) window.renderMembers();
414
+ alert('Left channel. Run "agentchannel leave --channel <name>" in CLI to persist.');
415
+ };
416
+
417
+ window.copyCode = function(btn) {
418
+ var code = btn.parentElement.querySelector('code');
419
+ if (code) {
420
+ navigator.clipboard.writeText(code.textContent);
421
+ btn.textContent = 'copied!';
422
+ setTimeout(function() { btn.textContent = 'copy'; }, 1500);
423
+ }
424
+ };
425
+
426
+ window.copyMsg = function(btn) {
427
+ navigator.clipboard.writeText(btn.dataset.msg);
428
+ btn.textContent = 'copied!';
429
+ setTimeout(function() { btn.textContent = 'copy'; }, 1500);
430
+ };
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Init
434
+ // ---------------------------------------------------------------------------
435
+ async function init() {
436
+ renderSidebar();
437
+
438
+ window.acChannels = window.acChannels || {};
439
+ var channels = window.acChannels;
440
+
441
+ for (var i = 0; i < CONFIG.channels.length; i++) {
442
+ var ch = CONFIG.channels[i];
443
+ var id = ch.subchannel ? ch.channel + '/' + ch.subchannel : ch.channel;
444
+ if (ch.subchannel) {
445
+ channels[id] = {
446
+ key: await deriveSubKeyWeb(ch.key, ch.subchannel),
447
+ hash: await hashSubWeb(ch.key, ch.subchannel),
448
+ name: ch.channel,
449
+ sub: ch.subchannel
450
+ };
451
+ } else {
452
+ channels[id] = {
453
+ key: await deriveKey(ch.key),
454
+ hash: await hashRoom(ch.key),
455
+ name: ch.channel
456
+ };
457
+ }
458
+ }
459
+
460
+ // Load history from D1 cloud — also discover subchannels from channel_meta
461
+ var pendingSubs = [];
462
+ for (var chKey in channels) {
463
+ var ch = channels[chKey];
464
+ try {
465
+ var res = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + ch.hash + "&since=0&limit=100");
466
+ var rows = await res.json();
467
+ for (var ri = 0; ri < rows.length; ri++) {
468
+ try {
469
+ var msg = JSON.parse(await decryptPayload(rows[ri].ciphertext, ch.key));
470
+ msg.channel = ch.name;
471
+ if (ch.sub) msg.subchannel = ch.sub;
472
+ if (msg.type === "channel_meta") {
473
+ try {
474
+ var meta = JSON.parse(msg.content);
475
+ if (!ch.sub) channelMetas[ch.name] = meta;
476
+ if (meta.subchannels && !ch.sub) {
477
+ var parentCfg = CONFIG.channels.find(function(c) { return c.channel === ch.name && !c.subchannel; });
478
+ if (parentCfg) {
479
+ for (var si = 0; si < meta.subchannels.length; si++) {
480
+ var subName = meta.subchannels[si];
481
+ var subId = ch.name + '/' + subName;
482
+ if (!channels[subId]) pendingSubs.push({name: ch.name, sub: subName, key: parentCfg.key});
483
+ }
484
+ }
485
+ }
486
+ } catch(e) {}
487
+ continue;
488
+ }
489
+ allMessages.push(msg);
490
+ } catch(e) {}
491
+ }
492
+ } catch(e) {}
493
+ }
494
+
495
+ // Subscribe to discovered subchannels
496
+ for (var psi = 0; psi < pendingSubs.length; psi++) {
497
+ var ps = pendingSubs[psi];
498
+ var subId = ps.name + '/' + ps.sub;
499
+ if (channels[subId]) continue;
500
+ var subKey = await deriveSubKeyWeb(ps.key, ps.sub);
501
+ var subHash = await hashSubWeb(ps.key, ps.sub);
502
+ channels[subId] = {key: subKey, hash: subHash, name: ps.name, sub: ps.sub};
503
+ CONFIG.channels.push({channel: ps.name, subchannel: ps.sub, key: ps.key});
504
+ // Load subchannel history
505
+ try {
506
+ var sres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=100");
507
+ var srows = await sres.json();
508
+ for (var sri = 0; sri < srows.length; sri++) {
509
+ try {
510
+ var smsg = JSON.parse(await decryptPayload(srows[sri].ciphertext, subKey));
511
+ smsg.channel = ps.name;
512
+ smsg.subchannel = ps.sub;
513
+ if (smsg.type !== "channel_meta") allMessages.push(smsg);
514
+ } catch(e) {}
515
+ }
516
+ } catch(e) {}
517
+ }
518
+
519
+ allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
520
+ renderSidebar();
521
+ render();
522
+
523
+ // Connect to MQTT for real-time messages
524
+ var client = mqtt.connect("wss://broker.emqx.io:8084/mqtt");
525
+ var userNameEl = document.getElementById("user-name");
526
+ var userStatusEl = document.getElementById("user-status");
527
+ var userAvatarEl = document.getElementById("user-avatar");
528
+ if (userNameEl) {
529
+ userNameEl.textContent = "@" + CONFIG.name + (CONFIG.fingerprint ? " (" + CONFIG.fingerprint.slice(0, 4) + ")" : "");
530
+ userAvatarEl.textContent = CONFIG.name ? CONFIG.name.charAt(0).toUpperCase() : "#";
531
+ }
532
+
533
+ client.on("connect", function() {
534
+ if (userStatusEl) {
535
+ userStatusEl.textContent = "v" + (CONFIG.version || "?") + " · connected";
536
+ userStatusEl.style.color = "#22c55e";
537
+ }
538
+ for (var chKey in channels) {
539
+ var ch = channels[chKey];
540
+ client.subscribe("ac/1/" + ch.hash);
541
+ client.subscribe("ac/1/" + ch.hash + "/p");
542
+ }
543
+ // Check for updates
544
+ fetch("https://registry.npmjs.org/agentchannel/latest").then(function(r) {
545
+ return r.json();
546
+ }).then(function(d) {
547
+ if (d.version && d.version !== CONFIG.version) {
548
+ statusEl.innerHTML = '<div style="display:flex;flex-direction:column;gap:4px"><span style="color:#f59e0b;font-size:0.7rem">v' + d.version + ' available</span><span id="update-copy" style="font-size:0.65rem;color:var(--text-muted);cursor:pointer;opacity:0.8">click to copy update cmd</span></div>';
549
+ document.getElementById("update-copy").onclick = function() {
550
+ navigator.clipboard.writeText("npm install -g agentchannel");
551
+ this.textContent = "copied!";
552
+ };
553
+ }
554
+ }).catch(function() {});
555
+ });
556
+
557
+ client.on("close", function() {
558
+ if (userStatusEl) { userStatusEl.textContent = "disconnected"; userStatusEl.style.color = ""; }
559
+ statusEl.className = "sidebar__status";
560
+ });
561
+
562
+ if (Notification.permission === "default") Notification.requestPermission();
563
+
564
+ window.cloudMembers = window.cloudMembers || {};
565
+ var cloudMembers = window.cloudMembers;
566
+
567
+ async function loadCloudMembers() {
568
+ for (var chKey in channels) {
569
+ var ch = channels[chKey];
570
+ try {
571
+ var res = await fetch("https://api.agentchannel.workers.dev/members?channel_hash=" + ch.hash);
572
+ var rows = await res.json();
573
+ var cid = ch.sub ? ch.name + '/' + ch.sub : ch.name;
574
+ cloudMembers[cid] = rows;
575
+ if (ch.sub && !cloudMembers[ch.name + '/' + ch.sub]) {
576
+ cloudMembers[ch.name + '/' + ch.sub] = cloudMembers[ch.name] || rows;
577
+ }
578
+ } catch(e) {}
579
+ }
580
+ // Ensure all subchannels have parent's members
581
+ for (var chKey in channels) {
582
+ var ch = channels[chKey];
583
+ if (ch.sub) {
584
+ var subId = ch.name + '/' + ch.sub;
585
+ var parentMembers = cloudMembers[ch.name] || [];
586
+ var subMembers = cloudMembers[subId] || [];
587
+ var merged = {};
588
+ for (var mi = 0; mi < parentMembers.length; mi++) merged[parentMembers[mi].name] = parentMembers[mi];
589
+ for (var mi = 0; mi < subMembers.length; mi++) merged[subMembers[mi].name] = subMembers[mi];
590
+ cloudMembers[subId] = Object.values(merged);
591
+ }
592
+ }
593
+ renderMembers();
594
+ }
595
+
596
+ function renderMembers() {
597
+ var list = document.getElementById("members-list");
598
+ var panel = document.getElementById("members-panel");
599
+ var header = document.querySelector(".members__header");
600
+ if (!list || !panel) return;
601
+
602
+ var memberMap = {};
603
+ var online = new Set();
604
+
605
+ // Collect online from presence
606
+ if (activeChannel === "all") {
607
+ for (var k in onlineMembers) {
608
+ for (var n of onlineMembers[k]) online.add(n);
609
+ }
610
+ } else {
611
+ var s = onlineMembers[activeChannel];
612
+ if (s) for (var n of s) online.add(n);
613
+ }
614
+
615
+ // Collect from cloud members — dedup by fingerprint, use latest name
616
+ var fpMap = {};
617
+ var nameToFp = {};
618
+
619
+ function addMember(name, fp, isOnline) {
620
+ var nameLower = name.toLowerCase();
621
+ if (fp) nameToFp[nameLower] = fp;
622
+ var resolvedFp = fp || nameToFp[nameLower];
623
+ var key = resolvedFp || nameLower;
624
+ var existing = fpMap[key];
625
+ if (!existing) {
626
+ fpMap[key] = {name: name, online: isOnline, fingerprint: resolvedFp};
627
+ } else {
628
+ if (resolvedFp && !existing.fingerprint) existing.fingerprint = resolvedFp;
629
+ if (name !== name.toLowerCase() && existing.name === existing.name.toLowerCase()) existing.name = name;
630
+ if (isOnline) existing.online = true;
631
+ }
632
+ if (resolvedFp && fpMap[nameLower] && nameLower !== key) delete fpMap[nameLower];
633
+ }
634
+
635
+ if (activeChannel === "all") {
636
+ var allCloudMembers = window.cloudMembers || {};
637
+ for (var ck in allCloudMembers) {
638
+ var rows = allCloudMembers[ck];
639
+ for (var ri = 0; ri < rows.length; ri++) addMember(rows[ri].name, rows[ri].fingerprint, online.has(rows[ri].name));
640
+ }
641
+ } else {
642
+ var crows = (window.cloudMembers || {})[activeChannel] || [];
643
+ for (var ri = 0; ri < crows.length; ri++) addMember(crows[ri].name, crows[ri].fingerprint, online.has(crows[ri].name));
644
+ }
645
+
646
+ // Also from message history
647
+ var msgs = activeChannel === "all"
648
+ ? allMessages
649
+ : allMessages.filter(function(m) {
650
+ var mid = m.subchannel ? m.channel + '/' + m.subchannel : m.channel;
651
+ return mid === activeChannel || m.channel === activeChannel;
652
+ });
653
+ for (var mi = 0; mi < msgs.length; mi++) {
654
+ var m = msgs[mi];
655
+ if (m.sender && m.type !== "system") addMember(m.sender, m.senderKey, online.has(m.sender));
656
+ }
657
+
658
+ // Always include self
659
+ addMember(CONFIG.name, CONFIG.fingerprint, true);
660
+
661
+ // Convert to memberMap
662
+ for (var k in fpMap) memberMap[fpMap[k].name] = fpMap[k];
663
+
664
+ // Sort: online first, then alphabetical
665
+ var sorted = Object.keys(memberMap).sort(function(a, b) {
666
+ if (memberMap[b].online !== memberMap[a].online) return memberMap[b].online ? 1 : -1;
667
+ return a.localeCompare(b);
668
+ });
669
+
670
+ var memberCount = sorted.length;
671
+ if (header) header.textContent = "Members (" + memberCount + ")";
672
+
673
+ var html = sorted.map(function(name) {
674
+ var isOnline = memberMap[name].online;
675
+ var isYou = name === CONFIG.name;
676
+ var memberInfo = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.name === name; });
677
+ var fpStr = memberInfo && memberInfo.fingerprint
678
+ ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + memberInfo.fingerprint.slice(0, 4) + ')</span>'
679
+ : '';
680
+ return '<div class="members__item"><span class="members__dot" style="background:' + (isOnline ? "#22c55e" : "#666") + '"></span><span class="members__name">' + esc(name) + fpStr + '</span>' + (isYou ? '<span class="members__role">you</span>' : '') + '</div>';
681
+ }).join("");
682
+
683
+ list.innerHTML = html;
684
+
685
+ // Update actions (fixed at bottom)
686
+ var actions = document.getElementById("members-actions");
687
+ if (actions) {
688
+ if (activeChannel !== "all" && activeChannel.toLowerCase() !== "agentchannel") {
689
+ actions.innerHTML = '<button class="members__btn" onclick="window.shareChannel()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg> Share</button>'
690
+ + '<button class="members__btn members__btn--leave" onclick="window.leaveChannel()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> Leave</button>';
691
+ } else {
692
+ actions.innerHTML = "";
693
+ }
694
+ }
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // MQTT message handler
699
+ // ---------------------------------------------------------------------------
700
+ client.on("message", async function(topic, payload) {
701
+ for (var chKey in channels) {
702
+ var ch = channels[chKey];
703
+
704
+ // Presence messages
705
+ if (topic === "ac/1/" + ch.hash + "/p") {
706
+ try {
707
+ var data = JSON.parse(payload.toString());
708
+ var pKey = ch.sub ? ch.name + '/' + ch.sub : ch.name;
709
+ if (!onlineMembers[pKey]) onlineMembers[pKey] = new Set();
710
+ if (data.status === "online") onlineMembers[pKey].add(data.name);
711
+ else onlineMembers[pKey].delete(data.name);
712
+ renderMembers();
713
+ } catch(e) {}
714
+ return;
715
+ }
716
+
717
+ // Channel messages
718
+ if (topic === "ac/1/" + ch.hash) {
719
+ try {
720
+ var msg = JSON.parse(await decryptPayload(payload.toString(), ch.key));
721
+ msg.channel = ch.name;
722
+ if (ch.sub) msg.subchannel = ch.sub;
723
+
724
+ // Handle channel_meta: auto-discover and subscribe to new subchannels
725
+ if (msg.type === "channel_meta") {
726
+ try {
727
+ var meta = JSON.parse(msg.content);
728
+ if (!ch.sub) channelMetas[ch.name] = meta;
729
+ if (meta.subchannels && meta.subchannels.length > 0) {
730
+ for (var si = 0; si < meta.subchannels.length; si++) {
731
+ var subName = meta.subchannels[si];
732
+ var subId = ch.name + '/' + subName;
733
+ if (!channels[subId]) {
734
+ var parentCfg = CONFIG.channels.find(function(c) { return c.channel === ch.name && !c.subchannel; });
735
+ if (parentCfg) {
736
+ var subKey = await deriveSubKeyWeb(parentCfg.key, subName);
737
+ var subHash = await hashSubWeb(parentCfg.key, subName);
738
+ channels[subId] = {key: subKey, hash: subHash, name: ch.name, sub: subName};
739
+ CONFIG.channels.push({channel: ch.name, subchannel: subName, key: parentCfg.key});
740
+ client.subscribe("ac/1/" + subHash);
741
+ client.subscribe("ac/1/" + subHash + "/p");
742
+ // Load history for new subchannel
743
+ try {
744
+ var hres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=100");
745
+ var hrows = await hres.json();
746
+ for (var hi = 0; hi < hrows.length; hi++) {
747
+ try {
748
+ var hmsg = JSON.parse(await decryptPayload(hrows[hi].ciphertext, subKey));
749
+ hmsg.channel = ch.name;
750
+ hmsg.subchannel = subName;
751
+ if (hmsg.type !== "channel_meta") allMessages.push(hmsg);
752
+ } catch(e) {}
753
+ }
754
+ allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
755
+ } catch(e) {}
756
+ renderSidebar();
757
+ render();
758
+ }
759
+ }
760
+ }
761
+ }
762
+ } catch(e) {}
763
+ return;
764
+ }
765
+
766
+ allMessages.push(msg);
767
+
768
+ // Track sender as online
769
+ var chKeyName = ch.sub ? ch.name + '/' + ch.sub : ch.name;
770
+ if (!onlineMembers[chKeyName]) onlineMembers[chKeyName] = new Set();
771
+ onlineMembers[chKeyName].add(msg.sender);
772
+
773
+ if (msg.sender !== CONFIG.name) {
774
+ if (activeChannel !== chKeyName && activeChannel !== "all") {
775
+ unreadCounts[chKeyName] = (unreadCounts[chKeyName] || 0) + 1;
776
+ renderSidebar();
777
+ }
778
+ var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
779
+ if (total > 0) document.title = "(" + total + ") AgentChannel";
780
+ var nlabel = ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name;
781
+ if (Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
782
+ var n = new Notification(nlabel + " @" + msg.sender, {body: msg.content});
783
+ n.onclick = function() {
784
+ window.focus();
785
+ if (ch.sub) { window.switchToSub(ch.sub); }
786
+ else { window.switchToChannel(ch.name); }
787
+ };
788
+ }
789
+ }
790
+ render();
791
+ renderMembers();
792
+ } catch(e) {}
793
+ }
794
+ }
795
+ });
796
+
797
+ window.renderMembers = renderMembers;
798
+
799
+ window.switchToChannel = function(name) {
800
+ activeChannel = name;
801
+ unreadCounts[name] = 0;
802
+ headerName.textContent = "#" + name;
803
+ headerDesc.textContent = (channelMetas[name] && channelMetas[name].description) || "";
804
+ document.title = "AgentChannel";
805
+ history.pushState(null, "", "/channel/" + encodeURIComponent(name));
806
+ renderSidebar();
807
+ render();
808
+ renderMembers();
809
+ };
810
+
811
+ window.switchToSub = function(subName) {
812
+ var parent = CONFIG.channels.find(function(c) { return c.subchannel === subName; });
813
+ if (!parent) return;
814
+ var cid = parent.channel + "/" + subName;
815
+ activeChannel = cid;
816
+ unreadCounts[cid] = 0;
817
+ headerName.textContent = "##" + subName;
818
+ var subDesc2 = (channelMetas[parent.channel] && channelMetas[parent.channel].descriptions && channelMetas[parent.channel].descriptions[subName]) || "";
819
+ headerDesc.textContent = "#" + parent.channel + (subDesc2 ? " \u00B7 " + subDesc2 : "");
820
+ document.title = "AgentChannel";
821
+ history.pushState(null, "", "/channel/" + encodeURIComponent(parent.channel) + "/sub/" + encodeURIComponent(subName));
822
+ renderSidebar();
823
+ render();
824
+ renderMembers();
825
+ };
826
+
827
+ renderMembers();
828
+ loadCloudMembers();
829
+ }
830
+
831
+ // Handle initial active channel from URL or server-injected config
832
+ if (window.__AC_INITIAL_CHANNEL__) {
833
+ activeChannel = window.__AC_INITIAL_CHANNEL__;
834
+ }
835
+
836
+ init();