agentchannel 0.7.19 → 0.7.21
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/dist/cli.js +4 -1
- package/dist/cli.js.map +1 -1
- package/dist/web.d.ts +1 -0
- package/dist/web.js +102 -786
- package/dist/web.js.map +1 -1
- package/package.json +3 -2
- package/ui/app.js +884 -0
- package/ui/index.html +46 -0
- package/ui/logo-circle.svg +10 -0
- package/ui/logo.svg +20 -0
- package/ui/style.css +150 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, '"') + '" 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 style="color:var(--text-muted);margin-right:2px">#</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 style="color:var(--accent);margin-right:2px;opacity:0.7">#</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 style="color:var(--accent);margin-right:2px;opacity:0.5">##</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 — parallel fetch all channels
|
|
461
|
+
var pendingSubs = [];
|
|
462
|
+
var fetchPromises = Object.keys(channels).map(function(chKey) {
|
|
463
|
+
var ch = channels[chKey];
|
|
464
|
+
return fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + ch.hash + "&since=0&limit=30")
|
|
465
|
+
.then(function(r) { return r.json(); })
|
|
466
|
+
.then(async function(rows) {
|
|
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(function() {});
|
|
493
|
+
});
|
|
494
|
+
await Promise.all(fetchPromises);
|
|
495
|
+
|
|
496
|
+
// Subscribe to discovered subchannels
|
|
497
|
+
for (var psi = 0; psi < pendingSubs.length; psi++) {
|
|
498
|
+
var ps = pendingSubs[psi];
|
|
499
|
+
var subId = ps.name + '/' + ps.sub;
|
|
500
|
+
if (channels[subId]) continue;
|
|
501
|
+
var subKey = await deriveSubKeyWeb(ps.key, ps.sub);
|
|
502
|
+
var subHash = await hashSubWeb(ps.key, ps.sub);
|
|
503
|
+
channels[subId] = {key: subKey, hash: subHash, name: ps.name, sub: ps.sub};
|
|
504
|
+
CONFIG.channels.push({channel: ps.name, subchannel: ps.sub, key: ps.key});
|
|
505
|
+
// Load subchannel history
|
|
506
|
+
try {
|
|
507
|
+
var sres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=30");
|
|
508
|
+
var srows = await sres.json();
|
|
509
|
+
for (var sri = 0; sri < srows.length; sri++) {
|
|
510
|
+
try {
|
|
511
|
+
var smsg = JSON.parse(await decryptPayload(srows[sri].ciphertext, subKey));
|
|
512
|
+
smsg.channel = ps.name;
|
|
513
|
+
smsg.subchannel = ps.sub;
|
|
514
|
+
if (smsg.type !== "channel_meta") allMessages.push(smsg);
|
|
515
|
+
} catch(e) {}
|
|
516
|
+
}
|
|
517
|
+
} catch(e) {}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
|
|
521
|
+
renderSidebar();
|
|
522
|
+
render();
|
|
523
|
+
|
|
524
|
+
// Show user name immediately (don't wait for MQTT)
|
|
525
|
+
var userNameEl = document.getElementById("user-name");
|
|
526
|
+
if (userNameEl) {
|
|
527
|
+
userNameEl.textContent = "@" + CONFIG.name + (CONFIG.fingerprint ? " (" + CONFIG.fingerprint.slice(0, 4) + ")" : "");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Connect to MQTT for real-time messages
|
|
531
|
+
var client = mqtt.connect("wss://broker.emqx.io:8084/mqtt");
|
|
532
|
+
|
|
533
|
+
client.on("connect", function() {
|
|
534
|
+
var userBar = document.getElementById("user-info");
|
|
535
|
+
if (userBar) userBar.classList.add("connected");
|
|
536
|
+
for (var chKey in channels) {
|
|
537
|
+
var ch = channels[chKey];
|
|
538
|
+
client.subscribe("ac/1/" + ch.hash);
|
|
539
|
+
client.subscribe("ac/1/" + ch.hash + "/p");
|
|
540
|
+
}
|
|
541
|
+
// Check for updates — show banner
|
|
542
|
+
fetch("https://registry.npmjs.org/agentchannel/latest").then(function(r) {
|
|
543
|
+
return r.json();
|
|
544
|
+
}).then(function(d) {
|
|
545
|
+
if (d.version && d.version !== CONFIG.version) {
|
|
546
|
+
var banner = document.getElementById("update-banner");
|
|
547
|
+
if (banner) {
|
|
548
|
+
banner.textContent = "v" + d.version + " available — click to copy update command";
|
|
549
|
+
banner.style.display = "block";
|
|
550
|
+
banner.onclick = function() {
|
|
551
|
+
navigator.clipboard.writeText("npm install -g agentchannel");
|
|
552
|
+
banner.textContent = "Copied! Run in terminal, then restart.";
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}).catch(function() {});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
client.on("close", function() {
|
|
560
|
+
var userBar2 = document.getElementById("user-info");
|
|
561
|
+
if (userBar2) userBar2.classList.remove("connected");
|
|
562
|
+
statusEl.className = "sidebar__status";
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
if (Notification.permission === "default") Notification.requestPermission();
|
|
566
|
+
|
|
567
|
+
window.cloudMembers = window.cloudMembers || {};
|
|
568
|
+
var cloudMembers = window.cloudMembers;
|
|
569
|
+
|
|
570
|
+
async function loadCloudMembers() {
|
|
571
|
+
for (var chKey in channels) {
|
|
572
|
+
var ch = channels[chKey];
|
|
573
|
+
try {
|
|
574
|
+
var res = await fetch("https://api.agentchannel.workers.dev/members?channel_hash=" + ch.hash);
|
|
575
|
+
var rows = await res.json();
|
|
576
|
+
var cid = ch.sub ? ch.name + '/' + ch.sub : ch.name;
|
|
577
|
+
cloudMembers[cid] = rows;
|
|
578
|
+
if (ch.sub && !cloudMembers[ch.name + '/' + ch.sub]) {
|
|
579
|
+
cloudMembers[ch.name + '/' + ch.sub] = cloudMembers[ch.name] || rows;
|
|
580
|
+
}
|
|
581
|
+
} catch(e) {}
|
|
582
|
+
}
|
|
583
|
+
// Ensure all subchannels have parent's members
|
|
584
|
+
for (var chKey in channels) {
|
|
585
|
+
var ch = channels[chKey];
|
|
586
|
+
if (ch.sub) {
|
|
587
|
+
var subId = ch.name + '/' + ch.sub;
|
|
588
|
+
var parentMembers = cloudMembers[ch.name] || [];
|
|
589
|
+
var subMembers = cloudMembers[subId] || [];
|
|
590
|
+
var merged = {};
|
|
591
|
+
for (var mi = 0; mi < parentMembers.length; mi++) merged[parentMembers[mi].name] = parentMembers[mi];
|
|
592
|
+
for (var mi = 0; mi < subMembers.length; mi++) merged[subMembers[mi].name] = subMembers[mi];
|
|
593
|
+
cloudMembers[subId] = Object.values(merged);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
renderMembers();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function renderMembers() {
|
|
600
|
+
var list = document.getElementById("members-list");
|
|
601
|
+
var panel = document.getElementById("members-panel");
|
|
602
|
+
var header = document.querySelector(".members__header");
|
|
603
|
+
if (!list || !panel) return;
|
|
604
|
+
|
|
605
|
+
// Hide members for All channels and public channels
|
|
606
|
+
if (activeChannel === "all" || activeChannel.toLowerCase() === "agentchannel") {
|
|
607
|
+
if (header) header.textContent = "MEMBERS";
|
|
608
|
+
list.innerHTML = "";
|
|
609
|
+
panel.style.display = "none";
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
panel.style.display = "";
|
|
613
|
+
|
|
614
|
+
var memberMap = {};
|
|
615
|
+
var online = new Set();
|
|
616
|
+
|
|
617
|
+
// Collect online from presence
|
|
618
|
+
if (activeChannel === "all") {
|
|
619
|
+
for (var k in onlineMembers) {
|
|
620
|
+
for (var n of onlineMembers[k]) online.add(n);
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
var s = onlineMembers[activeChannel];
|
|
624
|
+
if (s) for (var n of s) online.add(n);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Collect from cloud members — dedup by fingerprint, use latest name
|
|
628
|
+
var fpMap = {};
|
|
629
|
+
var nameToFp = {};
|
|
630
|
+
|
|
631
|
+
function addMember(name, fp, isOnline) {
|
|
632
|
+
if (!name) return;
|
|
633
|
+
var nameLower = name.toLowerCase();
|
|
634
|
+
if (fp) nameToFp[nameLower] = fp;
|
|
635
|
+
var resolvedFp = fp || nameToFp[nameLower];
|
|
636
|
+
var key = resolvedFp || nameLower;
|
|
637
|
+
|
|
638
|
+
// Remove any existing entry with same name but no fp (if we now have fp)
|
|
639
|
+
if (resolvedFp) {
|
|
640
|
+
for (var k in fpMap) {
|
|
641
|
+
if (k !== key && fpMap[k].name.toLowerCase() === nameLower) delete fpMap[k];
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
var existing = fpMap[key];
|
|
646
|
+
if (!existing) {
|
|
647
|
+
fpMap[key] = {name: name, online: isOnline, fingerprint: resolvedFp};
|
|
648
|
+
} else {
|
|
649
|
+
// Keep the most recent / capitalized name
|
|
650
|
+
if (name.length >= existing.name.length) existing.name = name;
|
|
651
|
+
if (resolvedFp) existing.fingerprint = resolvedFp;
|
|
652
|
+
if (isOnline) existing.online = true;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (activeChannel === "all") {
|
|
657
|
+
var allCloudMembers = window.cloudMembers || {};
|
|
658
|
+
for (var ck in allCloudMembers) {
|
|
659
|
+
var rows = allCloudMembers[ck];
|
|
660
|
+
for (var ri = 0; ri < rows.length; ri++) addMember(rows[ri].name, rows[ri].fingerprint, online.has(rows[ri].name));
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
var crows = (window.cloudMembers || {})[activeChannel] || [];
|
|
664
|
+
for (var ri = 0; ri < crows.length; ri++) addMember(crows[ri].name, crows[ri].fingerprint, online.has(crows[ri].name));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Also from message history
|
|
668
|
+
var msgs = activeChannel === "all"
|
|
669
|
+
? allMessages
|
|
670
|
+
: allMessages.filter(function(m) {
|
|
671
|
+
var mid = m.subchannel ? m.channel + '/' + m.subchannel : m.channel;
|
|
672
|
+
return mid === activeChannel || m.channel === activeChannel;
|
|
673
|
+
});
|
|
674
|
+
for (var mi = 0; mi < msgs.length; mi++) {
|
|
675
|
+
var m = msgs[mi];
|
|
676
|
+
if (m.sender && m.type !== "system") addMember(m.sender, m.senderKey, online.has(m.sender));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Always include self
|
|
680
|
+
addMember(CONFIG.name, CONFIG.fingerprint, true);
|
|
681
|
+
|
|
682
|
+
// Convert to memberMap
|
|
683
|
+
for (var k in fpMap) memberMap[fpMap[k].name] = fpMap[k];
|
|
684
|
+
|
|
685
|
+
// Sort: online first, then alphabetical
|
|
686
|
+
var sorted = Object.keys(memberMap).sort(function(a, b) {
|
|
687
|
+
if (memberMap[b].online !== memberMap[a].online) return memberMap[b].online ? 1 : -1;
|
|
688
|
+
return a.localeCompare(b);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
var memberCount = sorted.length;
|
|
692
|
+
if (header) header.textContent = "Members (" + memberCount + ")";
|
|
693
|
+
|
|
694
|
+
var html = sorted.map(function(name) {
|
|
695
|
+
var isOnline = memberMap[name].online;
|
|
696
|
+
var isYou = name === CONFIG.name;
|
|
697
|
+
var memberInfo = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.name === name; });
|
|
698
|
+
var fpStr = memberInfo && memberInfo.fingerprint
|
|
699
|
+
? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + memberInfo.fingerprint.slice(0, 4) + ')</span>'
|
|
700
|
+
: '';
|
|
701
|
+
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>';
|
|
702
|
+
}).join("");
|
|
703
|
+
|
|
704
|
+
list.innerHTML = html;
|
|
705
|
+
|
|
706
|
+
// Update actions (fixed at bottom)
|
|
707
|
+
var actions = document.getElementById("members-actions");
|
|
708
|
+
if (actions) {
|
|
709
|
+
if (activeChannel !== "all" && activeChannel.toLowerCase() !== "agentchannel") {
|
|
710
|
+
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>'
|
|
711
|
+
+ '<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>';
|
|
712
|
+
} else {
|
|
713
|
+
actions.innerHTML = "";
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
// MQTT message handler
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
client.on("message", async function(topic, payload) {
|
|
722
|
+
for (var chKey in channels) {
|
|
723
|
+
var ch = channels[chKey];
|
|
724
|
+
|
|
725
|
+
// Presence messages
|
|
726
|
+
if (topic === "ac/1/" + ch.hash + "/p") {
|
|
727
|
+
try {
|
|
728
|
+
var data = JSON.parse(payload.toString());
|
|
729
|
+
var pKey = ch.sub ? ch.name + '/' + ch.sub : ch.name;
|
|
730
|
+
if (!onlineMembers[pKey]) onlineMembers[pKey] = new Set();
|
|
731
|
+
if (data.status === "online") onlineMembers[pKey].add(data.name);
|
|
732
|
+
else onlineMembers[pKey].delete(data.name);
|
|
733
|
+
renderMembers();
|
|
734
|
+
} catch(e) {}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Channel messages
|
|
739
|
+
if (topic === "ac/1/" + ch.hash) {
|
|
740
|
+
try {
|
|
741
|
+
var msg = JSON.parse(await decryptPayload(payload.toString(), ch.key));
|
|
742
|
+
msg.channel = ch.name;
|
|
743
|
+
if (ch.sub) msg.subchannel = ch.sub;
|
|
744
|
+
|
|
745
|
+
// Handle channel_meta: auto-discover and subscribe to new subchannels
|
|
746
|
+
if (msg.type === "channel_meta") {
|
|
747
|
+
try {
|
|
748
|
+
var meta = JSON.parse(msg.content);
|
|
749
|
+
if (!ch.sub) channelMetas[ch.name] = meta;
|
|
750
|
+
if (meta.subchannels && meta.subchannels.length > 0) {
|
|
751
|
+
for (var si = 0; si < meta.subchannels.length; si++) {
|
|
752
|
+
var subName = meta.subchannels[si];
|
|
753
|
+
var subId = ch.name + '/' + subName;
|
|
754
|
+
if (!channels[subId]) {
|
|
755
|
+
var parentCfg = CONFIG.channels.find(function(c) { return c.channel === ch.name && !c.subchannel; });
|
|
756
|
+
if (parentCfg) {
|
|
757
|
+
var subKey = await deriveSubKeyWeb(parentCfg.key, subName);
|
|
758
|
+
var subHash = await hashSubWeb(parentCfg.key, subName);
|
|
759
|
+
channels[subId] = {key: subKey, hash: subHash, name: ch.name, sub: subName};
|
|
760
|
+
CONFIG.channels.push({channel: ch.name, subchannel: subName, key: parentCfg.key});
|
|
761
|
+
client.subscribe("ac/1/" + subHash);
|
|
762
|
+
client.subscribe("ac/1/" + subHash + "/p");
|
|
763
|
+
// Load history for new subchannel
|
|
764
|
+
try {
|
|
765
|
+
var hres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=30");
|
|
766
|
+
var hrows = await hres.json();
|
|
767
|
+
for (var hi = 0; hi < hrows.length; hi++) {
|
|
768
|
+
try {
|
|
769
|
+
var hmsg = JSON.parse(await decryptPayload(hrows[hi].ciphertext, subKey));
|
|
770
|
+
hmsg.channel = ch.name;
|
|
771
|
+
hmsg.subchannel = subName;
|
|
772
|
+
if (hmsg.type !== "channel_meta") allMessages.push(hmsg);
|
|
773
|
+
} catch(e) {}
|
|
774
|
+
}
|
|
775
|
+
allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
|
|
776
|
+
} catch(e) {}
|
|
777
|
+
renderSidebar();
|
|
778
|
+
render();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
} catch(e) {}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
allMessages.push(msg);
|
|
788
|
+
|
|
789
|
+
// Track sender as online
|
|
790
|
+
var chKeyName = ch.sub ? ch.name + '/' + ch.sub : ch.name;
|
|
791
|
+
if (!onlineMembers[chKeyName]) onlineMembers[chKeyName] = new Set();
|
|
792
|
+
onlineMembers[chKeyName].add(msg.sender);
|
|
793
|
+
|
|
794
|
+
if (msg.sender !== CONFIG.name) {
|
|
795
|
+
if (activeChannel !== chKeyName && activeChannel !== "all") {
|
|
796
|
+
unreadCounts[chKeyName] = (unreadCounts[chKeyName] || 0) + 1;
|
|
797
|
+
renderSidebar();
|
|
798
|
+
}
|
|
799
|
+
var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
|
|
800
|
+
if (total > 0) document.title = "(" + total + ") AgentChannel";
|
|
801
|
+
var nlabel = ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name;
|
|
802
|
+
if (Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
|
|
803
|
+
var n = new Notification(nlabel + " @" + msg.sender, {body: msg.content});
|
|
804
|
+
n.onclick = function() {
|
|
805
|
+
window.focus();
|
|
806
|
+
if (ch.sub) { window.switchToSub(ch.sub); }
|
|
807
|
+
else { window.switchToChannel(ch.name); }
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
render();
|
|
812
|
+
renderMembers();
|
|
813
|
+
} catch(e) {}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
window.renderMembers = renderMembers;
|
|
819
|
+
|
|
820
|
+
window.switchToChannel = function(name) {
|
|
821
|
+
activeChannel = name;
|
|
822
|
+
unreadCounts[name] = 0;
|
|
823
|
+
headerName.textContent = "#" + name;
|
|
824
|
+
headerDesc.textContent = (channelMetas[name] && channelMetas[name].description) || "";
|
|
825
|
+
document.title = "AgentChannel";
|
|
826
|
+
history.pushState(null, "", "/channel/" + encodeURIComponent(name));
|
|
827
|
+
renderSidebar();
|
|
828
|
+
render();
|
|
829
|
+
renderMembers();
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
window.switchToSub = function(subName) {
|
|
833
|
+
var parent = CONFIG.channels.find(function(c) { return c.subchannel === subName; });
|
|
834
|
+
if (!parent) return;
|
|
835
|
+
var cid = parent.channel + "/" + subName;
|
|
836
|
+
activeChannel = cid;
|
|
837
|
+
unreadCounts[cid] = 0;
|
|
838
|
+
headerName.textContent = "##" + subName;
|
|
839
|
+
var subDesc2 = (channelMetas[parent.channel] && channelMetas[parent.channel].descriptions && channelMetas[parent.channel].descriptions[subName]) || "";
|
|
840
|
+
headerDesc.textContent = "#" + parent.channel + (subDesc2 ? " \u00B7 " + subDesc2 : "");
|
|
841
|
+
document.title = "AgentChannel";
|
|
842
|
+
history.pushState(null, "", "/channel/" + encodeURIComponent(parent.channel) + "/sub/" + encodeURIComponent(subName));
|
|
843
|
+
renderSidebar();
|
|
844
|
+
render();
|
|
845
|
+
renderMembers();
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
renderMembers();
|
|
849
|
+
loadCloudMembers();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Handle initial active channel from URL or server-injected config
|
|
853
|
+
if (window.__AC_INITIAL_CHANNEL__) {
|
|
854
|
+
activeChannel = window.__AC_INITIAL_CHANNEL__;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Theme toggle: dark ↔ light only
|
|
858
|
+
var sunIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
|
|
859
|
+
var moonIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
|
860
|
+
|
|
861
|
+
function toggleTheme() {
|
|
862
|
+
var root = document.documentElement;
|
|
863
|
+
var btn = document.getElementById('theme-toggle');
|
|
864
|
+
var isDark = root.classList.contains('dark');
|
|
865
|
+
root.classList.remove('dark', 'light');
|
|
866
|
+
if (isDark) {
|
|
867
|
+
root.classList.add('light');
|
|
868
|
+
btn.innerHTML = moonIcon;
|
|
869
|
+
localStorage.setItem('ac-theme', 'light');
|
|
870
|
+
} else {
|
|
871
|
+
root.classList.add('dark');
|
|
872
|
+
btn.innerHTML = sunIcon;
|
|
873
|
+
localStorage.setItem('ac-theme', 'dark');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
window.toggleTheme = toggleTheme;
|
|
877
|
+
|
|
878
|
+
// Restore saved theme (default: dark)
|
|
879
|
+
var savedTheme = localStorage.getItem('ac-theme') || 'dark';
|
|
880
|
+
document.documentElement.classList.add(savedTheme);
|
|
881
|
+
var themeBtn = document.getElementById('theme-toggle');
|
|
882
|
+
if (themeBtn) themeBtn.innerHTML = savedTheme === 'dark' ? sunIcon : moonIcon;
|
|
883
|
+
|
|
884
|
+
init();
|