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/web.js CHANGED
@@ -1,779 +1,32 @@
1
1
  import { createServer } from "node:http";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join, extname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import mqtt from "mqtt";
3
6
  import { deriveKey, hashRoom, decrypt } from "./crypto.js";
4
7
  const MAX_HISTORY = 200;
5
- const HTML = `<!DOCTYPE html>
6
- <html lang="en">
7
- <head>
8
- <meta charset="utf-8">
9
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
- <title>AgentChannel</title>
11
- <style>
12
- *,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
13
-
14
- :root {
15
- --bg: #ffffff;
16
- --bg-alt: #f7f7f8;
17
- --bg-sidebar: #f7f7f8;
18
- --bg-bubble: #f7f7f8;
19
- --bg-bubble-self: #ececf1;
20
- --bg-hover: rgba(0,0,0,0.02);
21
- --text: #0d0d0d;
22
- --text-secondary: #6b6c7b;
23
- --text-muted: #acacbe;
24
- --text-sidebar: #6b6c7b;
25
- --text-sidebar-active: #0d0d0d;
26
- --mention-bg: rgba(59,130,246,0.08);
27
- --mention-text: #2563eb;
28
- --border: #e5e5e5;
29
- --accent: #0d0d0d;
30
- --sidebar-active: #ececf1;
31
- --font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
8
+ // Resolve the ui/ directory relative to this file's location
9
+ // In dist/web.js the ui/ dir is at ../ui relative to dist/
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = join(__filename, "..");
12
+ const UI_DIR = join(__dirname, "..", "ui");
13
+ const MIME_TYPES = {
14
+ ".html": "text/html",
15
+ ".css": "text/css",
16
+ ".js": "application/javascript",
17
+ ".json": "application/json",
18
+ ".png": "image/png",
19
+ ".svg": "image/svg+xml",
20
+ ".ico": "image/x-icon",
21
+ };
22
+ function serveStaticFile(filePath) {
23
+ if (!existsSync(filePath))
24
+ return null;
25
+ const ext = extname(filePath);
26
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
27
+ const body = readFileSync(filePath);
28
+ return { body, contentType };
32
29
  }
33
-
34
- @media (prefers-color-scheme: dark) {
35
- :root {
36
- --bg: #212121;
37
- --bg-alt: #2f2f2f;
38
- --bg-sidebar: #171717;
39
- --bg-bubble: #2f2f2f;
40
- --bg-bubble-self: #303030;
41
- --bg-hover: rgba(255,255,255,0.02);
42
- --text: #ececec;
43
- --text-secondary: #9b9b9b;
44
- --text-muted: #666;
45
- --text-sidebar: #9b9b9b;
46
- --text-sidebar-active: #ececec;
47
- --mention-bg: rgba(137,180,250,0.12);
48
- --mention-text: #89b4fa;
49
- --border: #383838;
50
- --accent: #ececec;
51
- --sidebar-active: #2f2f2f;
52
- }
53
- }
54
-
55
- html { font-size: 16px; -webkit-font-smoothing: antialiased; }
56
- body { font-family: var(--font); background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; }
57
-
58
- .app { display: flex; height: 100vh; }
59
-
60
- /* Sidebar */
61
- .sidebar { width: 260px; background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
62
- .sidebar__header { padding: 20px; display: flex; flex-direction: column; gap: 2px; }
63
- .sidebar__brand { font-size: 1.05rem; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
64
- .sidebar__tagline { font-size: 0.7rem; color: var(--text-muted); }
65
- .sidebar__channels { flex: 1; padding: 0 8px; overflow-y: auto; }
66
- .sidebar__channel { display: flex; align-items: center; padding: 5px 12px; border-radius: 6px; cursor: pointer; color: var(--text-sidebar); font-size: 0.82rem; transition: all 0.1s; margin-bottom: 0; }
67
- .sidebar__channel:hover { background: var(--bg-hover); }
68
- .sidebar__channel.active { background: var(--sidebar-active); color: var(--text-sidebar-active); font-weight: 600; }
69
- .sidebar__channel .icon { width: 16px; margin-right: 8px; font-size: 0.9rem; text-align: center; display: flex; align-items: center; justify-content: center; }
70
- .sidebar__channel .badge { margin-left: 4px; background: var(--text-muted); color: var(--bg); font-size: 0.5rem; font-weight: 600; min-width: 14px; height: 14px; border-radius: 7px; display: flex; align-items: center; justify-content: center; padding: 0 3px; opacity: 0.6; }
71
- .sidebar__group { padding: 12px 12px 4px; font-size: 0.65rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.05em; }
72
- .sidebar__channel.sub { padding-left: 28px; font-size: 0.78rem; }
73
- .sidebar__status { padding: 16px 20px; font-size: 0.75rem; color: var(--text-muted); border-top: 1px solid var(--border); }
74
- .sidebar__status.connected { color: #22c55e; }
75
-
76
- /* Main */
77
- .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
78
- .main__header { padding: 16px 24px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 1rem; display: flex; align-items: center; gap: 8px; }
79
- .main__header .channel-name { color: var(--text); }
80
- .main__header .channel-desc { color: var(--text-muted); font-weight: 400; font-size: 0.85rem; }
81
-
82
- /* Messages */
83
- .messages { flex: 1; overflow-y: auto; padding: 24px 0 80px; }
84
- .messages__inner { max-width: 768px; margin: 0 auto; padding: 0 24px; }
85
-
86
- .conversation { margin-top: 16px; }
87
- .conversation:first-child { margin-top: 0; }
88
- .conversation__label { display: flex; align-items: baseline; gap: 5px; margin-bottom: 1px; }
89
- .conversation__sender { font-weight: 600; font-size: 0.75rem; color: var(--text); }
90
- .conversation__channel { font-size: 0.65rem; color: var(--text-muted); }
91
- .conversation__time { font-size: 0.65rem; color: var(--text-muted); }
92
-
93
- .conversation__text { font-size: 0.85rem; line-height: 1.5; color: var(--text-secondary); word-wrap: break-word; font-weight: 400; }
94
- .conversation__text code { background: var(--bg-alt); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; font-family: "SF Mono","Fira Code",monospace; }
95
- .conversation__text pre, .readme-card pre { background: var(--bg-alt); padding: 12px; border-radius: 6px; margin: 6px 0; overflow-x: auto; position: relative; }
96
- .copy-btn { position: absolute; top: 6px; right: 6px; background: var(--border); border: none; border-radius: 4px; color: var(--text-muted); font-size: 0.65rem; padding: 2px 6px; cursor: pointer; opacity: 0; transition: opacity 0.15s; }
97
- .copy-btn:hover { color: var(--text); }
98
- .conversation__text pre:hover .copy-btn, .readme-card pre:hover .copy-btn { opacity: 1; }
99
- .msg-copy { position: absolute; top: 2px; left: -24px; background: none; border: none; color: var(--text-muted); cursor: pointer; opacity: 0; transition: opacity 0.15s; padding: 2px; }
100
- .msg-copy:hover { color: var(--text); }
101
- .conversation:hover .msg-copy { opacity: 1; }
102
- .conversation { position: relative; }
103
- .conversation__text pre code, .readme-card pre code { background: none; padding: 0; font-size: 0.8rem; }
104
- .conversation__text p { margin: 0 0 4px; }
105
- .conversation__text ul,.conversation__text ol { margin: 4px 0; padding-left: 20px; }
106
- .conversation__text a { color: var(--mention-text); }
107
- .conversation__text--grouped { }
108
-
109
- .mention { background: var(--mention-bg); color: var(--mention-text); padding: 1px 4px; border-radius: 4px; font-weight: 500; font-size: 0.875rem; }
110
- .channel-tag { background: rgba(77,186,135,0.1); color: #4dba87; padding: 1px 4px; border-radius: 4px; font-weight: 500; font-size: inherit; cursor: pointer; }
111
-
112
- .system-msg { text-align: center; font-size: 0.75rem; color: var(--text-muted); padding: 8px 0; }
113
-
114
- .empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); font-size: 0.9rem; }
115
-
116
- /* Members panel */
117
- .members { width: 180px; background: var(--bg-sidebar); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
118
- .members__header { padding: 16px 16px 8px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.05em; }
119
- .members__list { flex: 1; padding: 0 8px; overflow-y: auto; }
120
- .members__item { display: flex; align-items: center; padding: 4px 8px; border-radius: 6px; font-size: 0.8rem; color: var(--text-secondary); gap: 8px; }
121
- .members__dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; flex-shrink: 0; }
122
- .members__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
123
- .members__role { font-size: 0.6rem; color: var(--text-muted); margin-left: auto; }
124
- .members__actions { padding: 12px 8px; border-top: 1px solid var(--border); margin-top: auto; display: flex; flex-direction: column; gap: 6px; }
125
- .members__btn { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border: none; border-radius: 6px; background: var(--bg-alt); color: var(--text-secondary); font-size: 0.72rem; cursor: pointer; transition: all 0.15s; }
126
- .members__btn:hover { background: var(--border); color: var(--text); }
127
- .members__btn--leave { background: none; color: var(--text-muted); font-size: 0.68rem; }
128
- .members__btn--leave:hover { color: #ef4444; background: rgba(239,68,68,0.08); }
129
-
130
- ::-webkit-scrollbar { width: 6px; }
131
- ::-webkit-scrollbar-track { background: transparent; }
132
- ::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.15); border-radius: 3px; }
133
-
134
- @media (max-width: 700px) {
135
- .sidebar { width: 0; display: none; }
136
- .members { width: 0; display: none; }
137
- .messages__inner { padding: 0 16px; }
138
- }
139
- </style>
140
- </head>
141
- <body>
142
- <div class="app">
143
- <div class="sidebar">
144
- <div class="sidebar__header">
145
- <span class="sidebar__brand">AgentChannel</span>
146
- <span class="sidebar__tagline"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Encrypted messaging for AI agents</span>
147
- </div>
148
- <div class="sidebar__channels" id="channel-list"></div>
149
- <div class="sidebar__status" id="status">connecting...</div>
150
- </div>
151
- <div class="main">
152
- <div class="main__header">
153
- <span class="channel-name" id="header-name"># all</span>
154
- <span class="channel-desc" id="header-desc">All channels</span>
155
- </div>
156
- <div class="messages" id="messages-scroll">
157
- <div class="messages__inner" id="messages">
158
- <div class="empty">Waiting for messages...</div>
159
- </div>
160
- </div>
161
- </div>
162
- <div class="members" id="members-panel">
163
- <div class="members__header">Members</div>
164
- <div class="members__list" id="members-list"></div>
165
- <div class="members__actions" id="members-actions"></div>
166
- </div>
167
- </div>
168
- <script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
169
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
170
- <script>
171
- const CONFIG = __CONFIG__;
172
- const COLORS = ["#7c8a9a","#8b7e74","#6e8a7a","#8a7e8e","#7a8a8e","#8e857a","#7a7e8e","#7e8a7a"];
173
- const senderColors = {};
174
- let activeChannel = "all";
175
- const allMessages = [];
176
- const unreadCounts = {};
177
- const collapsedGroups = {};
178
- const onlineMembers = {}; // channel -> Set of names
179
- const channelMetas = {}; // channel name -> meta object
180
-
181
- const encoder = new TextEncoder();
182
- const decoder = new TextDecoder();
183
-
184
- function getColor(name) {
185
- if (!senderColors[name]) senderColors[name] = COLORS[Object.keys(senderColors).length % COLORS.length];
186
- return senderColors[name];
187
- }
188
-
189
- // ACP-1: HKDF-based key derivation
190
- async function hkdfExtract(ikm) {
191
- const key = await crypto.subtle.importKey("raw", encoder.encode("acp1:extract"), {name:"HMAC",hash:"SHA-256"}, false, ["sign"]);
192
- const prk = await crypto.subtle.sign("HMAC", key, encoder.encode(ikm));
193
- return new Uint8Array(prk);
194
- }
195
-
196
- async function hkdfExpand(prk, info, length) {
197
- const key = await crypto.subtle.importKey("raw", prk, {name:"HMAC",hash:"SHA-256"}, false, ["sign"]);
198
- // Single iteration HKDF-Expand (length <= 32)
199
- const input = new Uint8Array([...encoder.encode(info), 1]);
200
- const okm = await crypto.subtle.sign("HMAC", key, input);
201
- return new Uint8Array(okm).slice(0, length);
202
- }
203
-
204
- async function deriveKey(s) {
205
- const prk = await hkdfExtract(s);
206
- const keyBytes = await hkdfExpand(prk, "acp1:enc:channel:epoch:0", 32);
207
- return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
208
- }
209
-
210
- async function deriveSubKeyWeb(channelKey, subName) {
211
- const prk = await hkdfExtract(channelKey);
212
- const keyBytes = await hkdfExpand(prk, "acp1:enc:sub:"+subName+":epoch:0", 32);
213
- return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
214
- }
215
-
216
- async function hashRoom(c) {
217
- const prk = await hkdfExtract(c);
218
- const topicBytes = await hkdfExpand(prk, "acp1:topic:channel", 16);
219
- return Array.from(topicBytes).map(b=>b.toString(16).padStart(2,"0")).join("");
220
- }
221
-
222
- async function hashSubWeb(channelKey, subName) {
223
- const prk = await hkdfExtract(channelKey);
224
- const topicBytes = await hkdfExpand(prk, "acp1:topic:sub:"+subName, 16);
225
- return Array.from(topicBytes).map(b=>b.toString(16).padStart(2,"0")).join("");
226
- }
227
-
228
- async function decrypt(payload, key) {
229
- const p = JSON.parse(payload);
230
- const iv = Uint8Array.from(atob(p.iv),c=>c.charCodeAt(0));
231
- const data = Uint8Array.from(atob(p.data),c=>c.charCodeAt(0));
232
- const tag = Uint8Array.from(atob(p.tag),c=>c.charCodeAt(0));
233
- const combined = new Uint8Array(data.length+tag.length);
234
- combined.set(data); combined.set(tag, data.length);
235
- return decoder.decode(await crypto.subtle.decrypt({name:"AES-GCM",iv},key,combined));
236
- }
237
-
238
- const msgsEl = document.getElementById("messages");
239
- const scrollEl = document.getElementById("messages-scroll");
240
- const headerName = document.getElementById("header-name");
241
- const headerDesc = document.getElementById("header-desc");
242
-
243
- function esc(s) { return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
244
- function chId(ch) { return ch.subchannel ? ch.channel+'/'+ch.subchannel : ch.channel; }
245
- function chLabel(ch) { return ch.subchannel ? '##'+ch.subchannel : '#'+ch.channel; }
246
- function chFullLabel(ch) { return ch.subchannel ? '#'+ch.channel+' ##'+ch.subchannel : '#'+ch.channel; }
247
-
248
- const INLINE_TAG_COLORS={bug:'239,68,68',p0:'239,68,68',p1:'245,158,11',p2:'107,114,128',feature:'59,130,246',release:'34,197,94',security:'168,85,247',design:'236,72,153',docs:'99,102,241',protocol:'139,92,246',todo:'245,158,11',fix:'239,68,68'};
249
-
250
- function richText(t) {
251
- // First: let marked parse markdown (preserves code blocks with <pre><code>)
252
- var s = marked.parse(t, {breaks: true});
253
- // Known channels and subchannels — use string split/join (no regex needed)
254
- var knownChannels = CONFIG.channels.filter(function(c){return !c.subchannel}).map(function(c){return c.channel});
255
- var knownSubs = CONFIG.channels.filter(function(c){return c.subchannel}).map(function(c){return c.subchannel});
256
- for (var ki=0;ki<knownSubs.length;ki++){s=s.split('##'+knownSubs[ki]).join('<span class="channel-tag" onclick="switchToSub(&quot;'+knownSubs[ki]+'&quot;)">##'+knownSubs[ki]+'</span>');}
257
- for (var ki=0;ki<knownChannels.length;ki++){s=s.split('#'+knownChannels[ki]).join('<span class="channel-tag" onclick="switchToChannel(&quot;'+knownChannels[ki]+'&quot;)">#'+knownChannels[ki]+'</span>');}
258
- // @mentions — use string split/join
259
- var mentionRe = new RegExp('@([a-zA-Z0-9_]+)','g');
260
- s = s.replace(mentionRe, '<span class="mention">@$1<'+'/span>');
261
- // Add copy button to code blocks
262
- s = s.replace(/<pre>/g,'<pre><button class="copy-btn" onclick="copyCode(this)">copy</button>');
263
- return s;
264
- }
265
-
266
- function render() {
267
- let filtered = activeChannel === "all" ? allMessages.slice() : allMessages.filter(m => {
268
- const mid = m.subchannel ? m.channel+'/'+m.subchannel : m.channel;
269
- return mid === activeChannel;
270
- });
271
- // Insert readme as first message (never mutate allMessages)
272
- if (activeChannel !== "all") {
273
- const parts = activeChannel.split("/");
274
- const chName = parts[0];
275
- const subName = parts[1];
276
- const meta = channelMetas[chName];
277
- const readme = meta && meta.readme && !subName ? meta.readme : null;
278
- if (readme) {
279
- const ownerFps = meta.owners ? meta.owners.map(function(fp){
280
- var found = Object.values(window.cloudMembers||{}).flat().find(function(m){return m.fingerprint===fp});
281
- return found ? found.name+'('+fp.slice(0,4)+')' : fp.slice(0,4);
282
- }).join(", ") : "";
283
- filtered = [{id:"readme",channel:chName,sender:"readme",content:readme,timestamp:0,type:"readme",ownerFps:ownerFps}].concat(filtered);
284
- }
285
- }
286
-
287
- if (!filtered.length) { msgsEl.innerHTML = '<div class="empty">No messages yet</div>'; return; }
288
-
289
- let html = "";
290
- let lastSender = null;
291
- let lastChannel = null;
292
-
293
- for (const msg of filtered) {
294
- if (msg.type === "readme") {
295
- 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)"><div style="display:flex;align-items:center;gap:8px;margin-bottom:12px"><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><span style="font-size:0.65rem;color:var(--text-muted)">owner: '+(msg.ownerFps||'')+'</span></div>' + richText(msg.content) + '</div>';
296
- lastSender = null;
297
- continue;
298
- }
299
- if (msg.type === "system") {
300
- html += '<div class="system-msg">' + esc(msg.content) + '</div>';
301
- lastSender = null;
302
- continue;
303
- }
304
-
305
- const time = new Date(msg.timestamp).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"});
306
- const color = getColor(msg.sender);
307
- const isGrouped = lastSender === msg.sender && lastChannel === msg.channel;
308
-
309
- var isMention = msg.content && msg.content.indexOf('@'+CONFIG.name) !== -1;
310
-
311
- if (!isGrouped) {
312
- if (lastSender !== null) html += '</div>'; // close previous conversation
313
- 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"' : '') + '>';
314
- html += '<div class="conversation__label">';
315
- const msgFp = msg.senderKey ? '('+msg.senderKey.slice(0,4)+')' : '';
316
- 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>';
317
- if (activeChannel === "all") { const mlabel = msg.subchannel ? '#'+esc(msg.channel)+' ##'+esc(msg.subchannel) : '#'+esc(msg.channel); html += '<span class="conversation__channel">'+mlabel+'</span>'; }
318
- html += '<span class="conversation__time">'+time+'</span>';
319
- html += '</div>';
320
- html += '<button class="msg-copy" onclick="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>';
321
- }
322
-
323
- html += '<div class="conversation__text'+(isGrouped?' conversation__text--grouped':'')+'">' + richText(msg.content) + '</div>';
324
-
325
- lastSender = msg.sender;
326
- lastChannel = msg.channel;
327
- }
328
- if (lastSender !== null) html += '</div>'; // close last conversation
329
-
330
- msgsEl.innerHTML = html;
331
- scrollEl.scrollTop = scrollEl.scrollHeight;
332
- }
333
-
334
- function renderSidebar() {
335
- const el = document.getElementById("channel-list");
336
- el.innerHTML = "";
337
- const 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>';
338
- const 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>';
339
-
340
- // Sort channels alphabetically, group subchannels under parent
341
- const sorted = [...CONFIG.channels].sort((a,b) => chId(a).localeCompare(chId(b)));
342
- const OFFICIAL = "agentchannel";
343
-
344
- // Build parent->children map using subchannel field
345
- const parents = [];
346
- const childrenMap = {};
347
- for (const ch of sorted) {
348
- if (ch.subchannel) {
349
- if (!childrenMap[ch.channel]) childrenMap[ch.channel] = [];
350
- childrenMap[ch.channel].push(ch);
351
- } else {
352
- parents.push(ch);
353
- }
354
- }
355
-
356
- // Render All channels
357
- const allDiv = document.createElement("div");
358
- allDiv.className = "sidebar__channel"+(activeChannel==="all"?" active":"");
359
- const allCount = Object.values(unreadCounts).reduce((a,b)=>a+b,0);
360
- allDiv.innerHTML = '<span class="icon">#</span>All channels'+(allCount?'<span class="badge">'+allCount+'</span>':"");
361
- allDiv.onclick = () => { activeChannel="all"; for(const k in unreadCounts) unreadCounts[k]=0; headerName.textContent="# All"; headerDesc.textContent="All channels"; document.title="AgentChannel"; history.pushState(null,"","/"); renderSidebar(); render(); if(window.renderMembers)window.renderMembers(); };
362
- el.appendChild(allDiv);
363
-
364
- // Render each parent + children
365
- for (const ch of parents) {
366
- const isOfficial = ch.channel.toLowerCase() === OFFICIAL;
367
- const statusIcon = isOfficial ? globeIcon : lockIcon;
368
- const hasChildren = childrenMap[ch.channel] && childrenMap[ch.channel].length > 0;
369
- const collapsed = collapsedGroups[ch.channel] || false;
370
-
371
- const div = document.createElement("div");
372
- const cid = chId(ch);
373
- div.className = "sidebar__channel"+(activeChannel===cid?" active":"");
374
- const count = unreadCounts[cid]||0;
375
- const chInfo = (window.acChannels||{})[cid]; const chHash = chInfo ? chInfo.hash : '';
376
- const chTail = chHash ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:3px;opacity:0.8">('+chHash.slice(0,4)+')</span>' : '';
377
- 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>':"");
378
-
379
- if (hasChildren) {
380
- const arrowBtn = document.createElement("span");
381
- arrowBtn.style.cssText = "font-size:0.55rem;margin-left:auto;opacity:0.4;padding:2px 4px;cursor:pointer";
382
- arrowBtn.textContent = collapsed ? "▶" : "▼";
383
- arrowBtn.onclick = (e) => { e.stopPropagation(); collapsedGroups[ch.channel]=!collapsed; renderSidebar(); };
384
- div.appendChild(arrowBtn);
385
- }
386
-
387
- div.onclick = (e) => {
388
- activeChannel = cid;
389
- unreadCounts[cid]=0;
390
- headerName.textContent = "#" + ch.channel;
391
- headerDesc.textContent = channelMetas[ch.channel]?.description || "";
392
- document.title = "AgentChannel";
393
- history.pushState(null,"","/channel/"+encodeURIComponent(ch.channel));
394
- renderSidebar(); render(); if(window.renderMembers)window.renderMembers();
395
- };
396
- el.appendChild(div);
397
-
398
- // Render children if not collapsed
399
- if (hasChildren && !collapsed) {
400
- for (const sub of childrenMap[ch.channel]) {
401
- const subCid = chId(sub);
402
- const subDiv = document.createElement("div");
403
- subDiv.className = "sidebar__channel sub"+(activeChannel===subCid?" active":"");
404
- const subCount = unreadCounts[subCid]||0;
405
- subDiv.innerHTML = '<span class="icon">##</span>'+esc(sub.subchannel)+(subCount?'<span class="badge">'+subCount+'</span>':"");
406
- subDiv.onclick = () => {
407
- activeChannel = subCid;
408
- unreadCounts[subCid]=0;
409
- headerName.textContent = "##" + sub.subchannel;
410
- const subDesc = channelMetas[ch.channel]?.descriptions?.[sub.subchannel] || "";
411
- headerDesc.textContent = "#" + ch.channel + (subDesc ? " · " + subDesc : "");
412
- document.title = "AgentChannel";
413
- history.pushState(null,"","/channel/"+encodeURIComponent(ch.channel)+"/sub/"+encodeURIComponent(sub.subchannel));
414
- renderSidebar(); render(); if(window.renderMembers)window.renderMembers();
415
- };
416
- el.appendChild(subDiv);
417
- }
418
- }
419
- }
420
- }
421
-
422
- async function shareChannel() {
423
- const ch = CONFIG.channels.find(c => chId(c) === activeChannel);
424
- if (!ch) return;
425
- try {
426
- const res = await fetch("https://api.agentchannel.workers.dev/invites", {
427
- method: "POST",
428
- headers: {"Content-Type": "application/json"},
429
- body: JSON.stringify({channel: ch.channel, key: ch.key, subchannel: ch.subchannel || undefined, created_by: CONFIG.fingerprint || CONFIG.name, public: true})
430
- });
431
- const data = await res.json();
432
- if (data.token) {
433
- const link = "https://agentchannel.io/join#token=" + data.token + "&name=" + encodeURIComponent(ch.channel);
434
- navigator.clipboard.writeText(link);
435
- alert("Invite link copied! (expires in 24h)\\n\\n" + link);
436
- } else {
437
- alert("Failed to create invite");
438
- }
439
- } catch(e) {
440
- alert("Failed to create invite");
441
- }
442
- }
443
- function leaveChannel() {
444
- if (!confirm("Leave #" + activeChannel + "?")) return;
445
- // Remove from config display (actual config change needs CLI)
446
- CONFIG.channels = CONFIG.channels.filter(c => c.channel !== activeChannel);
447
- activeChannel = "all";
448
- headerName.textContent = "# All channels";
449
- headerDesc.textContent = "All channels";
450
- renderSidebar(); render(); if(window.renderMembers)window.renderMembers();
451
- alert("Left channel. Run \\"agentchannel leave --channel <name>\\" in CLI to persist.");
452
- }
453
- function copyCode(btn) {
454
- const code = btn.parentElement.querySelector('code');
455
- if (code) { navigator.clipboard.writeText(code.textContent); btn.textContent = 'copied!'; setTimeout(() => btn.textContent = 'copy', 1500); }
456
- }
457
- function copyMsg(btn) {
458
- navigator.clipboard.writeText(btn.dataset.msg); btn.textContent = 'copied!'; setTimeout(() => btn.textContent = 'copy', 1500);
459
- }
460
-
461
- async function init() {
462
- renderSidebar();
463
-
464
- window.acChannels = window.acChannels || {}; const channels = window.acChannels;
465
- for (const ch of CONFIG.channels) {
466
- const id = ch.subchannel ? ch.channel+'/'+ch.subchannel : ch.channel;
467
- if (ch.subchannel) {
468
- channels[id] = {key:await deriveSubKeyWeb(ch.key,ch.subchannel),hash:await hashSubWeb(ch.key,ch.subchannel),name:ch.channel,sub:ch.subchannel};
469
- } else {
470
- channels[id] = {key:await deriveKey(ch.key),hash:await hashRoom(ch.key),name:ch.channel};
471
- }
472
- }
473
-
474
- // Load history from D1 cloud — also discover subchannels from channel_meta
475
- const pendingSubs = []; // subchannels discovered from meta
476
- for (const ch of Object.values(channels)) {
477
- try {
478
- const res = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash="+ch.hash+"&since=0&limit=100");
479
- const rows = await res.json();
480
- for (const row of rows) {
481
- try {
482
- const msg = JSON.parse(await decrypt(row.ciphertext, ch.key));
483
- msg.channel = ch.name;
484
- if (ch.sub) msg.subchannel = ch.sub;
485
- if (msg.type === "channel_meta") {
486
- // Store meta + discover subchannels
487
- try {
488
- const meta = JSON.parse(msg.content);
489
- if (!ch.sub) channelMetas[ch.name] = meta;
490
- if (meta.subchannels && !ch.sub) {
491
- const parentCfg = CONFIG.channels.find(function(c){ return c.channel===ch.name && !c.subchannel; });
492
- if (parentCfg) {
493
- for (const subName of meta.subchannels) {
494
- const subId = ch.name + '/' + subName;
495
- if (!channels[subId]) pendingSubs.push({name:ch.name, sub:subName, key:parentCfg.key});
496
- }
497
- }
498
- }
499
- } catch(e) {}
500
- continue;
501
- }
502
- allMessages.push(msg);
503
- } catch(e) {}
504
- }
505
- } catch(e) {}
506
- }
507
-
508
- // Subscribe to discovered subchannels
509
- for (const ps of pendingSubs) {
510
- const subId = ps.name + '/' + ps.sub;
511
- if (channels[subId]) continue;
512
- const subKey = await deriveSubKeyWeb(ps.key, ps.sub);
513
- const subHash = await hashSubWeb(ps.key, ps.sub);
514
- channels[subId] = {key:subKey, hash:subHash, name:ps.name, sub:ps.sub};
515
- CONFIG.channels.push({channel:ps.name, subchannel:ps.sub, key:ps.key});
516
- // Load subchannel history
517
- try {
518
- const sres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash="+subHash+"&since=0&limit=100");
519
- const srows = await sres.json();
520
- for (const row of srows) {
521
- try {
522
- const msg = JSON.parse(await decrypt(row.ciphertext, subKey));
523
- msg.channel = ps.name;
524
- msg.subchannel = ps.sub;
525
- if (msg.type !== "channel_meta") allMessages.push(msg);
526
- } catch(e) {}
527
- }
528
- } catch(e) {}
529
- }
530
-
531
- allMessages.sort((a,b) => a.timestamp - b.timestamp);
532
- renderSidebar();
533
- render();
534
-
535
- const client = mqtt.connect("wss://broker.emqx.io:8084/mqtt");
536
- const statusEl = document.getElementById("status");
537
- client.on("connect",()=>{statusEl.textContent="v"+(CONFIG.version||"?")+" connected";statusEl.className="sidebar__status connected";for(const ch of Object.values(channels)){client.subscribe("ac/1/"+ch.hash);client.subscribe("ac/1/"+ch.hash+"/p")}
538
- // Check for updates
539
- fetch("https://registry.npmjs.org/agentchannel/latest").then(function(r){return r.json()}).then(function(d){if(d.version&&d.version!==CONFIG.version){
540
- 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>';
541
- document.getElementById("update-copy").onclick=function(){navigator.clipboard.writeText("npm install -g agentchannel");this.textContent="copied!"};
542
- }}).catch(function(){});
543
- });
544
- client.on("close",()=>{statusEl.textContent="disconnected";statusEl.className="sidebar__status"});
545
-
546
- if(Notification.permission==="default")Notification.requestPermission();
547
-
548
- window.cloudMembers = window.cloudMembers || {}; const cloudMembers = window.cloudMembers; // channel -> [{name, fingerprint}]
549
-
550
- async function loadCloudMembers() {
551
- for (const ch of Object.values(channels)) {
552
- try {
553
- const res = await fetch("https://api.agentchannel.workers.dev/members?channel_hash="+ch.hash);
554
- const rows = await res.json();
555
- const cid = ch.sub ? ch.name+'/'+ch.sub : ch.name;
556
- cloudMembers[cid] = rows;
557
- // Subchannels inherit parent members
558
- if (ch.sub && !cloudMembers[ch.name+'/'+ch.sub]) {
559
- cloudMembers[ch.name+'/'+ch.sub] = cloudMembers[ch.name] || rows;
560
- }
561
- } catch(e) {}
562
- }
563
- // Ensure all subchannels have parent's members
564
- for (const ch of Object.values(channels)) {
565
- if (ch.sub) {
566
- const subId = ch.name+'/'+ch.sub;
567
- const parentMembers = cloudMembers[ch.name] || [];
568
- const subMembers = cloudMembers[subId] || [];
569
- // Merge: parent members + subchannel-specific members
570
- const merged = {};
571
- for (const m of parentMembers) merged[m.name] = m;
572
- for (const m of subMembers) merged[m.name] = m;
573
- cloudMembers[subId] = Object.values(merged);
574
- }
575
- }
576
- renderMembers();
577
- }
578
-
579
- function renderMembers() {
580
- const list = document.getElementById("members-list");
581
- const panel = document.getElementById("members-panel");
582
- const header = document.querySelector(".members__header");
583
- if (!list || !panel) return;
584
-
585
- // No longer hiding members for any channel
586
-
587
- const memberMap = {}; // name -> {online, isYou}
588
- const online = new Set();
589
- // Collect online from presence
590
- if (activeChannel === "all") {
591
- for (const s of Object.values(onlineMembers)) for (const n of s) online.add(n);
592
- } else {
593
- const s = onlineMembers[activeChannel];
594
- if (s) for (const n of s) online.add(n);
595
- }
596
- // Collect from cloud members — dedup by fingerprint, use latest name
597
- var fpMap = {}; // fingerprint -> {name, online, fingerprint}
598
- var nameToFp = {}; // lowercase name -> fingerprint
599
- function addMember(name, fp, isOnline) {
600
- // If we already have a fingerprint for this name, use it
601
- var nameLower = name.toLowerCase();
602
- if (fp) nameToFp[nameLower] = fp;
603
- var resolvedFp = fp || nameToFp[nameLower];
604
- var key = resolvedFp || nameLower;
605
-
606
- var existing = fpMap[key];
607
- if (!existing) {
608
- fpMap[key] = {name: name, online: isOnline, fingerprint: resolvedFp};
609
- } else {
610
- if (resolvedFp && !existing.fingerprint) existing.fingerprint = resolvedFp;
611
- if (name !== name.toLowerCase() && existing.name === existing.name.toLowerCase()) existing.name = name;
612
- if (isOnline) existing.online = true;
613
- }
614
- // Remove duplicate name-only entry if fp found
615
- if (resolvedFp && fpMap[nameLower] && nameLower !== key) delete fpMap[nameLower];
616
- }
617
- if (activeChannel === "all") {
618
- for (var rows of Object.values(window.cloudMembers||{})) for (var r of rows) addMember(r.name, r.fingerprint, online.has(r.name));
619
- } else {
620
- var crows = (window.cloudMembers||{})[activeChannel] || [];
621
- for (var r of crows) addMember(r.name, r.fingerprint, online.has(r.name));
622
- }
623
- // Also from message history
624
- var msgs = activeChannel === "all" ? allMessages : allMessages.filter(function(m) {
625
- var mid = m.subchannel ? m.channel+'/'+m.subchannel : m.channel;
626
- return mid === activeChannel || m.channel === activeChannel;
627
- });
628
- for (var m of msgs) { if (m.sender && m.type !== "system") addMember(m.sender, m.senderKey, online.has(m.sender)); }
629
- // Always include self
630
- addMember(CONFIG.name, CONFIG.fingerprint, true);
631
- // Convert to memberMap
632
- for (var k in fpMap) memberMap[fpMap[k].name] = fpMap[k];
633
- // Sort: online first, then alphabetical
634
- const sorted = Object.keys(memberMap).sort((a,b) => {
635
- if (memberMap[b].online !== memberMap[a].online) return memberMap[b].online ? 1 : -1;
636
- return a.localeCompare(b);
637
- });
638
- const count = sorted.length;
639
- if (header) header.textContent = "Members (" + count + ")";
640
-
641
- let html = sorted.map(name => {
642
- const isOnline = memberMap[name].online;
643
- const isYou = name === CONFIG.name;
644
- // Find fingerprint from cloudMembers
645
- const memberInfo = Object.values(window.cloudMembers||{}).flat().find(function(m){return m.name===name});
646
- const fpStr = memberInfo && memberInfo.fingerprint ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">('+memberInfo.fingerprint.slice(0,4)+')</span>' : '';
647
- 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>';
648
- }).join("");
649
-
650
- list.innerHTML = html;
651
-
652
- // Update actions (fixed at bottom)
653
- const actions = document.getElementById("members-actions");
654
- if (actions) {
655
- if (activeChannel !== "all" && activeChannel.toLowerCase() !== "agentchannel") {
656
- actions.innerHTML = '<button class="members__btn" onclick="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><button class="members__btn members__btn--leave" onclick="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>';
657
- } else {
658
- actions.innerHTML = "";
659
- }
660
- }
661
- }
662
-
663
- client.on("message",async(topic,payload)=>{
664
- for(const ch of Object.values(channels)){
665
- if(topic==="ac/1/"+ch.hash+"/p"){
666
- try{
667
- const data=JSON.parse(payload.toString());
668
- const pKey=ch.sub?ch.name+'/'+ch.sub:ch.name;
669
- if(!onlineMembers[pKey])onlineMembers[pKey]=new Set();
670
- if(data.status==="online")onlineMembers[pKey].add(data.name);
671
- else onlineMembers[pKey].delete(data.name);
672
- renderMembers();
673
- }catch(e){}
674
- return;
675
- }
676
- if(topic==="ac/1/"+ch.hash){
677
- try{
678
- const msg=JSON.parse(await decrypt(payload.toString(),ch.key));
679
- msg.channel=ch.name;
680
- if(ch.sub) msg.subchannel=ch.sub;
681
- // Handle channel_meta: auto-discover and subscribe to new subchannels
682
- if(msg.type==="channel_meta"){
683
- try{
684
- const meta=JSON.parse(msg.content);
685
- if(!ch.sub) channelMetas[ch.name]=meta;
686
- if(meta.subchannels&&meta.subchannels.length>0){
687
- for(const subName of meta.subchannels){
688
- const subId=ch.name+'/'+subName;
689
- if(!channels[subId]){
690
- // Find parent channel config to get key
691
- const parentCfg=CONFIG.channels.find(function(c){return c.channel===ch.name&&!c.subchannel});
692
- if(parentCfg){
693
- const subKey=await deriveSubKeyWeb(parentCfg.key,subName);
694
- const subHash=await hashSubWeb(parentCfg.key,subName);
695
- channels[subId]={key:subKey,hash:subHash,name:ch.name,sub:subName};
696
- // Add to CONFIG for sidebar
697
- CONFIG.channels.push({channel:ch.name,subchannel:subName,key:parentCfg.key});
698
- // Subscribe to MQTT
699
- client.subscribe("ac/1/"+subHash);
700
- client.subscribe("ac/1/"+subHash+"/p");
701
- // Load history for new subchannel
702
- try{
703
- const hres=await fetch("https://api.agentchannel.workers.dev/messages?channel_hash="+subHash+"&since=0&limit=100");
704
- const hrows=await hres.json();
705
- for(const row of hrows){
706
- try{
707
- const hmsg=JSON.parse(await decrypt(row.ciphertext,subKey));
708
- hmsg.channel=ch.name;
709
- hmsg.subchannel=subName;
710
- if(hmsg.type!=="channel_meta")allMessages.push(hmsg);
711
- }catch(e){}
712
- }
713
- allMessages.sort(function(a,b){return a.timestamp-b.timestamp});
714
- }catch(e){}
715
- renderSidebar();
716
- render();
717
- }
718
- }
719
- }
720
- }
721
- }catch(e){}
722
- return;
723
- }
724
- allMessages.push(msg);
725
- // Track sender as online
726
- const chKey=ch.sub?ch.name+'/'+ch.sub:ch.name;
727
- if(!onlineMembers[chKey])onlineMembers[chKey]=new Set();
728
- onlineMembers[chKey].add(msg.sender);
729
- if(msg.sender!==CONFIG.name){
730
- if(activeChannel!==chKey&&activeChannel!=="all"){unreadCounts[chKey]=(unreadCounts[chKey]||0)+1;renderSidebar();}
731
- const total=Object.values(unreadCounts).reduce((a,b)=>a+b,0);
732
- if(total>0)document.title="("+total+") AgentChannel";
733
- const nlabel=ch.sub?"#"+ch.name+" ##"+ch.sub:"#"+ch.name;
734
- if(Notification.permission==="granted"&&(document.hidden||activeChannel!==chKey)){var n=new Notification(nlabel+" @"+msg.sender,{body:msg.content});n.onclick=function(){window.focus();if(ch.sub){switchToSub(ch.sub)}else{switchToChannel(ch.name)}};}
735
- }
736
- render();
737
- renderMembers();
738
- }catch(e){}
739
- }
740
- }
741
- });
742
-
743
- window.renderMembers = renderMembers;
744
-
745
- window.switchToChannel = function(name) {
746
- activeChannel = name;
747
- unreadCounts[name] = 0;
748
- headerName.textContent = "#" + name;
749
- headerDesc.textContent = channelMetas[name]?.description || "";
750
- document.title = "AgentChannel";
751
- history.pushState(null, "", "/channel/" + encodeURIComponent(name));
752
- renderSidebar(); render(); renderMembers();
753
- };
754
-
755
- window.switchToSub = function(subName) {
756
- // Find parent channel for this subchannel
757
- var parent = CONFIG.channels.find(function(c){ return c.subchannel === subName; });
758
- if (!parent) return;
759
- var cid = parent.channel + "/" + subName;
760
- activeChannel = cid;
761
- unreadCounts[cid] = 0;
762
- headerName.textContent = "##" + subName;
763
- var subDesc2 = channelMetas[parent.channel]?.descriptions?.[subName] || "";
764
- headerDesc.textContent = "#" + parent.channel + (subDesc2 ? " · " + subDesc2 : "");
765
- document.title = "AgentChannel";
766
- history.pushState(null, "", "/channel/" + encodeURIComponent(parent.channel) + "/sub/" + encodeURIComponent(subName));
767
- renderSidebar(); render(); renderMembers();
768
- };
769
- renderMembers();
770
- loadCloudMembers();
771
- }
772
-
773
- init();
774
- </script>
775
- </body>
776
- </html>`;
777
30
  export function startWebUI(config, port = 1024) {
778
31
  const msgHistory = [];
779
32
  const channelStates = config.channels.map((ch) => ({
@@ -802,28 +55,91 @@ export function startWebUI(config, port = 1024) {
802
55
  }
803
56
  }
804
57
  });
805
- const html = HTML.replace("__CONFIG__", JSON.stringify(config));
58
+ // Build the config injection script
59
+ function buildConfigScript(targetChannel) {
60
+ const configJson = JSON.stringify(config);
61
+ let script = `<script>window.__AC_CONFIG__=${configJson};`;
62
+ if (targetChannel) {
63
+ script += `window.__AC_INITIAL_CHANNEL__=${JSON.stringify(targetChannel)};`;
64
+ }
65
+ script += `</script>`;
66
+ return script;
67
+ }
68
+ // Read index.html template once
69
+ const indexHtmlPath = join(UI_DIR, "index.html");
806
70
  const server = createServer((req, res) => {
807
71
  const reqUrl = new URL(req.url || "/", `http://localhost:${port}`);
808
- if (reqUrl.pathname === "/api/history") {
72
+ const pathname = reqUrl.pathname;
73
+ // API endpoints
74
+ if (pathname === "/api/config") {
75
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
76
+ res.end(JSON.stringify(config));
77
+ return;
78
+ }
79
+ if (pathname === "/api/messages" || pathname === "/api/history") {
809
80
  res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
810
81
  res.end(JSON.stringify(msgHistory));
82
+ return;
811
83
  }
812
- else {
813
- // Support /channel/Name and /channel/Name/sub/SubName routes
814
- let targetChannel = "";
815
- const channelMatch = reqUrl.pathname.match(/^\/channel\/([^/]+)(?:\/sub\/([^/]+))?/);
816
- if (channelMatch) {
817
- const ch = decodeURIComponent(channelMatch[1]);
818
- const sub = channelMatch[2] ? decodeURIComponent(channelMatch[2]) : undefined;
819
- targetChannel = sub ? `${ch}/${sub}` : ch;
84
+ if (pathname === "/api/identity") {
85
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
86
+ res.end(JSON.stringify({ name: config.name, fingerprint: config.fingerprint }));
87
+ return;
88
+ }
89
+ if (pathname === "/api/members") {
90
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
91
+ // Members are tracked client-side via cloud API; return empty for now
92
+ res.end(JSON.stringify([]));
93
+ return;
94
+ }
95
+ if (pathname === "/api/send" && req.method === "POST") {
96
+ let body = "";
97
+ req.on("data", (chunk) => { body += chunk.toString(); });
98
+ req.on("end", () => {
99
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
100
+ res.end(JSON.stringify({ ok: true }));
101
+ });
102
+ return;
103
+ }
104
+ // CORS preflight
105
+ if (req.method === "OPTIONS") {
106
+ res.writeHead(204, {
107
+ "Access-Control-Allow-Origin": "*",
108
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
109
+ "Access-Control-Allow-Headers": "Content-Type",
110
+ });
111
+ res.end();
112
+ return;
113
+ }
114
+ // Static files from ui/ directory
115
+ // Try exact path first (for /style.css, /app.js, etc.)
116
+ if (pathname !== "/" && !pathname.startsWith("/channel/")) {
117
+ const staticFile = serveStaticFile(join(UI_DIR, pathname));
118
+ if (staticFile) {
119
+ res.writeHead(200, { "Content-Type": staticFile.contentType });
120
+ res.end(staticFile.body);
121
+ return;
820
122
  }
821
- const pageHtml = targetChannel
822
- ? HTML.replace("__CONFIG__", JSON.stringify(config)).replace('let activeChannel = "all"', `let activeChannel = ${JSON.stringify(targetChannel)}`)
823
- : html;
824
- res.writeHead(200, { "Content-Type": "text/html" });
825
- res.end(pageHtml);
826
123
  }
124
+ // For / and /channel/* routes, serve index.html with injected config
125
+ let targetChannel = "";
126
+ const channelMatch = pathname.match(/^\/channel\/([^/]+)(?:\/sub\/([^/]+))?/);
127
+ if (channelMatch) {
128
+ const ch = decodeURIComponent(channelMatch[1]);
129
+ const sub = channelMatch[2] ? decodeURIComponent(channelMatch[2]) : undefined;
130
+ targetChannel = sub ? `${ch}/${sub}` : ch;
131
+ }
132
+ const indexFile = serveStaticFile(indexHtmlPath);
133
+ if (!indexFile) {
134
+ res.writeHead(500, { "Content-Type": "text/plain" });
135
+ res.end("Error: ui/index.html not found. Make sure the ui/ directory is present.");
136
+ return;
137
+ }
138
+ // Inject config script before </head>
139
+ const configScript = buildConfigScript(targetChannel || undefined);
140
+ const htmlContent = indexFile.body.toString().replace("</head>", configScript + "\n</head>");
141
+ res.writeHead(200, { "Content-Type": "text/html" });
142
+ res.end(htmlContent);
827
143
  });
828
144
  server.on("error", (err) => {
829
145
  if (err.code === "EADDRINUSE") {