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/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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
|
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("'+knownSubs[ki]+'")">##'+knownSubs[ki]+'</span>');}
|
|
257
|
-
for (var ki=0;ki<knownChannels.length;ki++){s=s.split('#'+knownChannels[ki]).join('<span class="channel-tag" onclick="switchToChannel("'+knownChannels[ki]+'")">#'+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,'"')+'" 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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") {
|