@agentlip/hub 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agentlip Hub UI handler
|
|
3
|
+
*
|
|
4
|
+
* Minimal HTML UI served at /ui/* routes with:
|
|
5
|
+
* - Channel list (/ui)
|
|
6
|
+
* - Topic list (/ui/channels/:channel_id)
|
|
7
|
+
* - Messages view (/ui/topics/:topic_id) with live updates via WS
|
|
8
|
+
*
|
|
9
|
+
* No build step: inline HTML/CSS/JS.
|
|
10
|
+
* Security: all user content escaped via textContent, URLs validated.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface UiContext {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
authToken: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Handle UI requests.
|
|
20
|
+
* Returns Response if route matches, null if not found.
|
|
21
|
+
*/
|
|
22
|
+
export function handleUiRequest(req: Request, ctx: UiContext): Response | null {
|
|
23
|
+
const url = new URL(req.url);
|
|
24
|
+
const path = url.pathname;
|
|
25
|
+
|
|
26
|
+
// Only accept GET requests
|
|
27
|
+
if (req.method !== "GET") {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// GET /ui → Channels list page
|
|
32
|
+
if (path === "/ui" || path === "/ui/") {
|
|
33
|
+
return renderChannelsListPage(ctx);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// GET /ui/channels/:channel_id → Topics list for a channel
|
|
37
|
+
const channelMatch = path.match(/^\/ui\/channels\/([^/]+)$/);
|
|
38
|
+
if (channelMatch) {
|
|
39
|
+
const channelId = channelMatch[1];
|
|
40
|
+
return renderTopicsListPage(ctx, channelId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// GET /ui/topics/:topic_id → Messages view for a topic
|
|
44
|
+
const topicMatch = path.match(/^\/ui\/topics\/([^/]+)$/);
|
|
45
|
+
if (topicMatch) {
|
|
46
|
+
const topicId = topicMatch[1];
|
|
47
|
+
return renderTopicMessagesPage(ctx, topicId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Route not found
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Page Renderers
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function renderChannelsListPage(ctx: UiContext): Response {
|
|
59
|
+
const html = `<!DOCTYPE html>
|
|
60
|
+
<html lang="en">
|
|
61
|
+
<head>
|
|
62
|
+
<meta charset="UTF-8">
|
|
63
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
64
|
+
<title>Agentlip - Channels</title>
|
|
65
|
+
<style>${getCommonStyles()}</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<div class="container">
|
|
69
|
+
<header>
|
|
70
|
+
<h1>Channels</h1>
|
|
71
|
+
</header>
|
|
72
|
+
<main>
|
|
73
|
+
<div id="loading">Loading channels...</div>
|
|
74
|
+
<div id="error" style="display:none"></div>
|
|
75
|
+
<ul id="channels-list" style="display:none"></ul>
|
|
76
|
+
</main>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<script>
|
|
80
|
+
const API_BASE = ${JSON.stringify(ctx.baseUrl)};
|
|
81
|
+
const AUTH_TOKEN = ${JSON.stringify(ctx.authToken)};
|
|
82
|
+
|
|
83
|
+
async function loadChannels() {
|
|
84
|
+
const loading = document.getElementById('loading');
|
|
85
|
+
const error = document.getElementById('error');
|
|
86
|
+
const list = document.getElementById('channels-list');
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(API_BASE + '/api/v1/channels', {
|
|
90
|
+
headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN }
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error('Failed to load channels: ' + res.status);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
const channels = data.channels || [];
|
|
99
|
+
|
|
100
|
+
loading.style.display = 'none';
|
|
101
|
+
|
|
102
|
+
if (channels.length === 0) {
|
|
103
|
+
error.textContent = 'No channels found';
|
|
104
|
+
error.style.display = 'block';
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Render channels
|
|
109
|
+
list.innerHTML = '';
|
|
110
|
+
for (const channel of channels) {
|
|
111
|
+
const li = document.createElement('li');
|
|
112
|
+
const link = document.createElement('a');
|
|
113
|
+
link.href = '/ui/channels/' + encodeURIComponent(channel.id);
|
|
114
|
+
link.textContent = channel.name;
|
|
115
|
+
|
|
116
|
+
if (channel.description) {
|
|
117
|
+
const desc = document.createElement('div');
|
|
118
|
+
desc.className = 'description';
|
|
119
|
+
desc.textContent = channel.description;
|
|
120
|
+
li.appendChild(link);
|
|
121
|
+
li.appendChild(desc);
|
|
122
|
+
} else {
|
|
123
|
+
li.appendChild(link);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
list.appendChild(li);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
list.style.display = 'block';
|
|
130
|
+
} catch (err) {
|
|
131
|
+
loading.style.display = 'none';
|
|
132
|
+
error.textContent = 'Error: ' + err.message;
|
|
133
|
+
error.style.display = 'block';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
loadChannels();
|
|
138
|
+
</script>
|
|
139
|
+
</body>
|
|
140
|
+
</html>`;
|
|
141
|
+
|
|
142
|
+
return new Response(html, {
|
|
143
|
+
status: 200,
|
|
144
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderTopicsListPage(ctx: UiContext, channelId: string): Response {
|
|
149
|
+
const html = `<!DOCTYPE html>
|
|
150
|
+
<html lang="en">
|
|
151
|
+
<head>
|
|
152
|
+
<meta charset="UTF-8">
|
|
153
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
154
|
+
<title>Agentlip - Topics</title>
|
|
155
|
+
<style>${getCommonStyles()}</style>
|
|
156
|
+
</head>
|
|
157
|
+
<body>
|
|
158
|
+
<div class="container">
|
|
159
|
+
<header>
|
|
160
|
+
<nav>
|
|
161
|
+
<a href="/ui">← Channels</a>
|
|
162
|
+
</nav>
|
|
163
|
+
<h1 id="channel-name">Topics</h1>
|
|
164
|
+
</header>
|
|
165
|
+
<main>
|
|
166
|
+
<div id="loading">Loading topics...</div>
|
|
167
|
+
<div id="error" style="display:none"></div>
|
|
168
|
+
<ul id="topics-list" style="display:none"></ul>
|
|
169
|
+
</main>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<script>
|
|
173
|
+
const API_BASE = ${JSON.stringify(ctx.baseUrl)};
|
|
174
|
+
const AUTH_TOKEN = ${JSON.stringify(ctx.authToken)};
|
|
175
|
+
const CHANNEL_ID = ${JSON.stringify(channelId)};
|
|
176
|
+
|
|
177
|
+
async function loadChannel() {
|
|
178
|
+
try {
|
|
179
|
+
const res = await fetch(API_BASE + '/api/v1/channels', {
|
|
180
|
+
headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (res.ok) {
|
|
184
|
+
const data = await res.json();
|
|
185
|
+
const channel = data.channels.find(ch => ch.id === CHANNEL_ID);
|
|
186
|
+
if (channel) {
|
|
187
|
+
document.getElementById('channel-name').textContent = channel.name;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Best effort - don't block topics load
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadTopics() {
|
|
196
|
+
const loading = document.getElementById('loading');
|
|
197
|
+
const error = document.getElementById('error');
|
|
198
|
+
const list = document.getElementById('topics-list');
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const res = await fetch(
|
|
202
|
+
API_BASE + '/api/v1/channels/' + encodeURIComponent(CHANNEL_ID) + '/topics',
|
|
203
|
+
{ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (!res.ok) {
|
|
207
|
+
throw new Error('Failed to load topics: ' + res.status);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const data = await res.json();
|
|
211
|
+
const topics = data.topics || [];
|
|
212
|
+
|
|
213
|
+
loading.style.display = 'none';
|
|
214
|
+
|
|
215
|
+
if (topics.length === 0) {
|
|
216
|
+
error.textContent = 'No topics found';
|
|
217
|
+
error.style.display = 'block';
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Render topics
|
|
222
|
+
list.innerHTML = '';
|
|
223
|
+
for (const topic of topics) {
|
|
224
|
+
const li = document.createElement('li');
|
|
225
|
+
const link = document.createElement('a');
|
|
226
|
+
link.href = '/ui/topics/' + encodeURIComponent(topic.id);
|
|
227
|
+
link.textContent = topic.title;
|
|
228
|
+
|
|
229
|
+
const meta = document.createElement('div');
|
|
230
|
+
meta.className = 'meta';
|
|
231
|
+
meta.textContent = 'Updated: ' + new Date(topic.updated_at).toLocaleString();
|
|
232
|
+
|
|
233
|
+
li.appendChild(link);
|
|
234
|
+
li.appendChild(meta);
|
|
235
|
+
list.appendChild(li);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
list.style.display = 'block';
|
|
239
|
+
} catch (err) {
|
|
240
|
+
loading.style.display = 'none';
|
|
241
|
+
error.textContent = 'Error: ' + err.message;
|
|
242
|
+
error.style.display = 'block';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
loadChannel();
|
|
247
|
+
loadTopics();
|
|
248
|
+
</script>
|
|
249
|
+
</body>
|
|
250
|
+
</html>`;
|
|
251
|
+
|
|
252
|
+
return new Response(html, {
|
|
253
|
+
status: 200,
|
|
254
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderTopicMessagesPage(ctx: UiContext, topicId: string): Response {
|
|
259
|
+
const html = `<!DOCTYPE html>
|
|
260
|
+
<html lang="en">
|
|
261
|
+
<head>
|
|
262
|
+
<meta charset="UTF-8">
|
|
263
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
264
|
+
<title>Agentlip - Messages</title>
|
|
265
|
+
<style>${getCommonStyles()}
|
|
266
|
+
.layout {
|
|
267
|
+
display: flex;
|
|
268
|
+
gap: 20px;
|
|
269
|
+
align-items: flex-start;
|
|
270
|
+
}
|
|
271
|
+
.messages-column {
|
|
272
|
+
flex: 1;
|
|
273
|
+
min-width: 0;
|
|
274
|
+
}
|
|
275
|
+
.attachments-column {
|
|
276
|
+
width: 300px;
|
|
277
|
+
flex-shrink: 0;
|
|
278
|
+
}
|
|
279
|
+
.message {
|
|
280
|
+
border-bottom: 1px solid var(--border-color);
|
|
281
|
+
padding: 12px 0;
|
|
282
|
+
}
|
|
283
|
+
.message:last-child {
|
|
284
|
+
border-bottom: none;
|
|
285
|
+
}
|
|
286
|
+
.message-header {
|
|
287
|
+
display: flex;
|
|
288
|
+
gap: 8px;
|
|
289
|
+
align-items: baseline;
|
|
290
|
+
margin-bottom: 6px;
|
|
291
|
+
font-size: 0.9em;
|
|
292
|
+
}
|
|
293
|
+
.sender {
|
|
294
|
+
font-weight: 600;
|
|
295
|
+
color: var(--primary-color);
|
|
296
|
+
}
|
|
297
|
+
.timestamp {
|
|
298
|
+
color: var(--meta-color);
|
|
299
|
+
font-size: 0.9em;
|
|
300
|
+
}
|
|
301
|
+
.badge {
|
|
302
|
+
font-size: 0.85em;
|
|
303
|
+
padding: 2px 6px;
|
|
304
|
+
border-radius: 3px;
|
|
305
|
+
background: var(--border-color);
|
|
306
|
+
color: var(--meta-color);
|
|
307
|
+
}
|
|
308
|
+
.content {
|
|
309
|
+
margin: 8px 0;
|
|
310
|
+
white-space: pre-wrap;
|
|
311
|
+
word-wrap: break-word;
|
|
312
|
+
}
|
|
313
|
+
.deleted {
|
|
314
|
+
color: var(--meta-color);
|
|
315
|
+
font-style: italic;
|
|
316
|
+
}
|
|
317
|
+
.attachment {
|
|
318
|
+
border: 1px solid var(--border-color);
|
|
319
|
+
border-radius: 6px;
|
|
320
|
+
padding: 8px;
|
|
321
|
+
margin-bottom: 8px;
|
|
322
|
+
}
|
|
323
|
+
.attachment-kind {
|
|
324
|
+
font-size: 0.85em;
|
|
325
|
+
font-weight: 600;
|
|
326
|
+
text-transform: uppercase;
|
|
327
|
+
color: var(--meta-color);
|
|
328
|
+
margin-bottom: 4px;
|
|
329
|
+
}
|
|
330
|
+
.attachment-url {
|
|
331
|
+
word-break: break-all;
|
|
332
|
+
font-size: 0.9em;
|
|
333
|
+
}
|
|
334
|
+
@media (max-width: 768px) {
|
|
335
|
+
.layout {
|
|
336
|
+
flex-direction: column;
|
|
337
|
+
}
|
|
338
|
+
.attachments-column {
|
|
339
|
+
width: 100%;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
</style>
|
|
343
|
+
</head>
|
|
344
|
+
<body>
|
|
345
|
+
<div class="container">
|
|
346
|
+
<header>
|
|
347
|
+
<nav>
|
|
348
|
+
<a href="/ui">← Channels</a>
|
|
349
|
+
<span id="breadcrumb"></span>
|
|
350
|
+
</nav>
|
|
351
|
+
<h1 id="topic-title">Messages</h1>
|
|
352
|
+
</header>
|
|
353
|
+
<main>
|
|
354
|
+
<div id="loading">Loading messages...</div>
|
|
355
|
+
<div id="error" style="display:none"></div>
|
|
356
|
+
<div id="content" class="layout" style="display:none">
|
|
357
|
+
<div class="messages-column">
|
|
358
|
+
<div id="messages-list"></div>
|
|
359
|
+
</div>
|
|
360
|
+
<div class="attachments-column">
|
|
361
|
+
<h2>Attachments</h2>
|
|
362
|
+
<div id="attachments-list"></div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</main>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<script>
|
|
369
|
+
const API_BASE = ${JSON.stringify(ctx.baseUrl)};
|
|
370
|
+
const AUTH_TOKEN = ${JSON.stringify(ctx.authToken)};
|
|
371
|
+
const TOPIC_ID = ${JSON.stringify(topicId)};
|
|
372
|
+
|
|
373
|
+
const state = {
|
|
374
|
+
messages: new Map(), // message_id -> message object
|
|
375
|
+
attachments: [],
|
|
376
|
+
highestEventId: 0,
|
|
377
|
+
ws: null,
|
|
378
|
+
topicData: null,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
382
|
+
// URL Validation (security)
|
|
383
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function isValidUrl(urlString) {
|
|
386
|
+
try {
|
|
387
|
+
const url = new URL(urlString);
|
|
388
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
389
|
+
} catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
395
|
+
// Rendering
|
|
396
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
function renderMessage(msg) {
|
|
399
|
+
const messageEl = document.createElement('div');
|
|
400
|
+
messageEl.className = 'message';
|
|
401
|
+
messageEl.dataset.messageId = msg.id;
|
|
402
|
+
|
|
403
|
+
const header = document.createElement('div');
|
|
404
|
+
header.className = 'message-header';
|
|
405
|
+
|
|
406
|
+
const sender = document.createElement('span');
|
|
407
|
+
sender.className = 'sender';
|
|
408
|
+
sender.textContent = msg.sender;
|
|
409
|
+
header.appendChild(sender);
|
|
410
|
+
|
|
411
|
+
const timestamp = document.createElement('span');
|
|
412
|
+
timestamp.className = 'timestamp';
|
|
413
|
+
timestamp.textContent = new Date(msg.created_at).toLocaleString();
|
|
414
|
+
header.appendChild(timestamp);
|
|
415
|
+
|
|
416
|
+
if (msg.version > 1) {
|
|
417
|
+
const versionBadge = document.createElement('span');
|
|
418
|
+
versionBadge.className = 'badge';
|
|
419
|
+
versionBadge.textContent = 'v' + msg.version;
|
|
420
|
+
header.appendChild(versionBadge);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (msg.edited_at) {
|
|
424
|
+
const editedBadge = document.createElement('span');
|
|
425
|
+
editedBadge.className = 'badge';
|
|
426
|
+
editedBadge.textContent = 'edited';
|
|
427
|
+
header.appendChild(editedBadge);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
messageEl.appendChild(header);
|
|
431
|
+
|
|
432
|
+
const content = document.createElement('div');
|
|
433
|
+
content.className = 'content';
|
|
434
|
+
|
|
435
|
+
if (msg.deleted_at) {
|
|
436
|
+
content.classList.add('deleted');
|
|
437
|
+
content.textContent = '[deleted by ' + (msg.deleted_by || 'unknown') + ']';
|
|
438
|
+
} else {
|
|
439
|
+
content.textContent = msg.content_raw;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
messageEl.appendChild(content);
|
|
443
|
+
|
|
444
|
+
return messageEl;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderMessages() {
|
|
448
|
+
const list = document.getElementById('messages-list');
|
|
449
|
+
list.innerHTML = '';
|
|
450
|
+
|
|
451
|
+
// Sort messages by created_at
|
|
452
|
+
const sorted = Array.from(state.messages.values())
|
|
453
|
+
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
454
|
+
|
|
455
|
+
for (const msg of sorted) {
|
|
456
|
+
list.appendChild(renderMessage(msg));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function updateMessage(msg) {
|
|
461
|
+
state.messages.set(msg.id, msg);
|
|
462
|
+
|
|
463
|
+
// Find existing message element
|
|
464
|
+
const existing = document.querySelector('[data-message-id="' + msg.id + '"]');
|
|
465
|
+
if (existing) {
|
|
466
|
+
existing.replaceWith(renderMessage(msg));
|
|
467
|
+
} else {
|
|
468
|
+
// New message - re-render all to maintain sort order
|
|
469
|
+
renderMessages();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function renderAttachment(att) {
|
|
474
|
+
const attEl = document.createElement('div');
|
|
475
|
+
attEl.className = 'attachment';
|
|
476
|
+
attEl.dataset.attachmentId = att.id;
|
|
477
|
+
|
|
478
|
+
const kind = document.createElement('div');
|
|
479
|
+
kind.className = 'attachment-kind';
|
|
480
|
+
kind.textContent = att.kind;
|
|
481
|
+
attEl.appendChild(kind);
|
|
482
|
+
|
|
483
|
+
if (att.kind === 'url' && att.value_json && att.value_json.url) {
|
|
484
|
+
const url = att.value_json.url;
|
|
485
|
+
|
|
486
|
+
if (isValidUrl(url)) {
|
|
487
|
+
const link = document.createElement('a');
|
|
488
|
+
link.className = 'attachment-url';
|
|
489
|
+
link.href = url;
|
|
490
|
+
link.target = '_blank';
|
|
491
|
+
link.rel = 'noopener noreferrer';
|
|
492
|
+
link.textContent = url;
|
|
493
|
+
attEl.appendChild(link);
|
|
494
|
+
} else {
|
|
495
|
+
const text = document.createElement('div');
|
|
496
|
+
text.className = 'attachment-url';
|
|
497
|
+
text.textContent = url;
|
|
498
|
+
attEl.appendChild(text);
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
const value = document.createElement('pre');
|
|
502
|
+
value.style.fontSize = '0.85em';
|
|
503
|
+
value.style.overflow = 'auto';
|
|
504
|
+
value.textContent = JSON.stringify(att.value_json, null, 2);
|
|
505
|
+
attEl.appendChild(value);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return attEl;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function renderAttachments() {
|
|
512
|
+
const list = document.getElementById('attachments-list');
|
|
513
|
+
list.innerHTML = '';
|
|
514
|
+
|
|
515
|
+
if (state.attachments.length === 0) {
|
|
516
|
+
list.textContent = 'No attachments';
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const att of state.attachments) {
|
|
521
|
+
list.appendChild(renderAttachment(att));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function addAttachment(att) {
|
|
526
|
+
// Check if already exists (dedupe)
|
|
527
|
+
const exists = state.attachments.find(a => a.id === att.id);
|
|
528
|
+
if (!exists) {
|
|
529
|
+
state.attachments.push(att);
|
|
530
|
+
renderAttachments();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
535
|
+
// Data Loading
|
|
536
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
async function loadTopic() {
|
|
539
|
+
try {
|
|
540
|
+
const res = await fetch(
|
|
541
|
+
API_BASE + '/api/v1/channels',
|
|
542
|
+
{ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
if (res.ok) {
|
|
546
|
+
const channelsData = await res.json();
|
|
547
|
+
// Find the channel that contains this topic
|
|
548
|
+
for (const channel of channelsData.channels || []) {
|
|
549
|
+
const topicsRes = await fetch(
|
|
550
|
+
API_BASE + '/api/v1/channels/' + encodeURIComponent(channel.id) + '/topics',
|
|
551
|
+
{ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
if (topicsRes.ok) {
|
|
555
|
+
const topicsData = await topicsRes.json();
|
|
556
|
+
const topic = topicsData.topics.find(t => t.id === TOPIC_ID);
|
|
557
|
+
|
|
558
|
+
if (topic) {
|
|
559
|
+
state.topicData = { ...topic, channel };
|
|
560
|
+
document.getElementById('topic-title').textContent = topic.title;
|
|
561
|
+
document.getElementById('breadcrumb').textContent = ' / ' + channel.name;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
// Best effort - don't block messages load
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function loadMessages() {
|
|
573
|
+
const loading = document.getElementById('loading');
|
|
574
|
+
const error = document.getElementById('error');
|
|
575
|
+
const content = document.getElementById('content');
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const res = await fetch(
|
|
579
|
+
API_BASE + '/api/v1/messages?topic_id=' + encodeURIComponent(TOPIC_ID) + '&limit=50',
|
|
580
|
+
{ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
if (!res.ok) {
|
|
584
|
+
throw new Error('Failed to load messages: ' + res.status);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const data = await res.json();
|
|
588
|
+
const messages = data.messages || [];
|
|
589
|
+
|
|
590
|
+
// Store messages
|
|
591
|
+
for (const msg of messages) {
|
|
592
|
+
state.messages.set(msg.id, msg);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
loading.style.display = 'none';
|
|
596
|
+
content.style.display = 'flex';
|
|
597
|
+
|
|
598
|
+
renderMessages();
|
|
599
|
+
} catch (err) {
|
|
600
|
+
loading.style.display = 'none';
|
|
601
|
+
error.textContent = 'Error loading messages: ' + err.message;
|
|
602
|
+
error.style.display = 'block';
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function loadAttachments() {
|
|
607
|
+
try {
|
|
608
|
+
const res = await fetch(
|
|
609
|
+
API_BASE + '/api/v1/topics/' + encodeURIComponent(TOPIC_ID) + '/attachments',
|
|
610
|
+
{ headers: { 'Authorization': 'Bearer ' + AUTH_TOKEN } }
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
if (res.ok) {
|
|
614
|
+
const data = await res.json();
|
|
615
|
+
state.attachments = data.attachments || [];
|
|
616
|
+
renderAttachments();
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
// Best effort - don't block page load
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
624
|
+
// WebSocket Live Updates
|
|
625
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
function connectWebSocket() {
|
|
628
|
+
// Determine highest event_id from messages
|
|
629
|
+
let highestEventId = 0;
|
|
630
|
+
for (const msg of state.messages.values()) {
|
|
631
|
+
// We don't have event_id on message objects, so we'll use 0
|
|
632
|
+
// The WS will send all new events after connection
|
|
633
|
+
}
|
|
634
|
+
state.highestEventId = highestEventId;
|
|
635
|
+
|
|
636
|
+
const wsUrl = new URL(API_BASE);
|
|
637
|
+
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
638
|
+
wsUrl.pathname = '/ws';
|
|
639
|
+
wsUrl.searchParams.set('token', AUTH_TOKEN);
|
|
640
|
+
|
|
641
|
+
const ws = new WebSocket(wsUrl.toString());
|
|
642
|
+
state.ws = ws;
|
|
643
|
+
|
|
644
|
+
ws.onopen = () => {
|
|
645
|
+
// Send hello message
|
|
646
|
+
ws.send(JSON.stringify({
|
|
647
|
+
type: 'hello',
|
|
648
|
+
after_event_id: state.highestEventId,
|
|
649
|
+
subscriptions: {
|
|
650
|
+
topics: [TOPIC_ID]
|
|
651
|
+
}
|
|
652
|
+
}));
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
ws.onmessage = (event) => {
|
|
656
|
+
try {
|
|
657
|
+
const msg = JSON.parse(event.data);
|
|
658
|
+
|
|
659
|
+
if (msg.type === 'hello_ok') {
|
|
660
|
+
state.highestEventId = msg.replay_until;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (msg.type === 'event') {
|
|
665
|
+
handleEvent(msg);
|
|
666
|
+
state.highestEventId = Math.max(state.highestEventId, msg.event_id);
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
console.error('WS message parse error:', err);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
ws.onerror = (err) => {
|
|
674
|
+
console.error('WebSocket error:', err);
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
ws.onclose = () => {
|
|
678
|
+
// Attempt to reconnect after 5s
|
|
679
|
+
setTimeout(connectWebSocket, 5000);
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function handleEvent(event) {
|
|
684
|
+
if (event.name === 'message.created' && event.data.message) {
|
|
685
|
+
updateMessage(event.data.message);
|
|
686
|
+
} else if (event.name === 'message.edited' && event.data.message) {
|
|
687
|
+
updateMessage(event.data.message);
|
|
688
|
+
} else if (event.name === 'message.deleted' && event.data.message) {
|
|
689
|
+
updateMessage(event.data.message);
|
|
690
|
+
} else if (event.name === 'topic.attachment_added' && event.data.attachment) {
|
|
691
|
+
addAttachment(event.data.attachment);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
696
|
+
// Initialize
|
|
697
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
async function init() {
|
|
700
|
+
await Promise.all([
|
|
701
|
+
loadTopic(),
|
|
702
|
+
loadMessages(),
|
|
703
|
+
loadAttachments(),
|
|
704
|
+
]);
|
|
705
|
+
|
|
706
|
+
// Start WebSocket connection after initial load
|
|
707
|
+
connectWebSocket();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
init();
|
|
711
|
+
</script>
|
|
712
|
+
</body>
|
|
713
|
+
</html>`;
|
|
714
|
+
|
|
715
|
+
return new Response(html, {
|
|
716
|
+
status: 200,
|
|
717
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
722
|
+
// Common Styles
|
|
723
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
function getCommonStyles(): string {
|
|
726
|
+
return `
|
|
727
|
+
:root {
|
|
728
|
+
--bg-color: #ffffff;
|
|
729
|
+
--text-color: #1a1a1a;
|
|
730
|
+
--border-color: #e0e0e0;
|
|
731
|
+
--primary-color: #0066cc;
|
|
732
|
+
--meta-color: #666;
|
|
733
|
+
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
@media (prefers-color-scheme: dark) {
|
|
737
|
+
:root {
|
|
738
|
+
--bg-color: #1a1a1a;
|
|
739
|
+
--text-color: #e0e0e0;
|
|
740
|
+
--border-color: #333;
|
|
741
|
+
--primary-color: #4d9fff;
|
|
742
|
+
--meta-color: #999;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
* {
|
|
747
|
+
margin: 0;
|
|
748
|
+
padding: 0;
|
|
749
|
+
box-sizing: border-box;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
body {
|
|
753
|
+
font-family: var(--font-family);
|
|
754
|
+
background: var(--bg-color);
|
|
755
|
+
color: var(--text-color);
|
|
756
|
+
line-height: 1.6;
|
|
757
|
+
padding: 20px;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
.container {
|
|
761
|
+
max-width: 1200px;
|
|
762
|
+
margin: 0 auto;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
header {
|
|
766
|
+
margin-bottom: 30px;
|
|
767
|
+
padding-bottom: 20px;
|
|
768
|
+
border-bottom: 2px solid var(--border-color);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
h1 {
|
|
772
|
+
font-size: 2em;
|
|
773
|
+
margin-bottom: 10px;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
h2 {
|
|
777
|
+
font-size: 1.3em;
|
|
778
|
+
margin-bottom: 15px;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
nav {
|
|
782
|
+
margin-bottom: 10px;
|
|
783
|
+
font-size: 0.9em;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
nav a {
|
|
787
|
+
color: var(--primary-color);
|
|
788
|
+
text-decoration: none;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
nav a:hover {
|
|
792
|
+
text-decoration: underline;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
ul {
|
|
796
|
+
list-style: none;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
li {
|
|
800
|
+
padding: 12px 0;
|
|
801
|
+
border-bottom: 1px solid var(--border-color);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
li:last-child {
|
|
805
|
+
border-bottom: none;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
li a {
|
|
809
|
+
color: var(--primary-color);
|
|
810
|
+
text-decoration: none;
|
|
811
|
+
font-size: 1.1em;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
li a:hover {
|
|
815
|
+
text-decoration: underline;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
.description {
|
|
819
|
+
color: var(--meta-color);
|
|
820
|
+
font-size: 0.9em;
|
|
821
|
+
margin-top: 4px;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
.meta {
|
|
825
|
+
color: var(--meta-color);
|
|
826
|
+
font-size: 0.85em;
|
|
827
|
+
margin-top: 4px;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
#loading {
|
|
831
|
+
color: var(--meta-color);
|
|
832
|
+
padding: 20px 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
#error {
|
|
836
|
+
color: #cc0000;
|
|
837
|
+
padding: 20px;
|
|
838
|
+
background: rgba(255, 0, 0, 0.1);
|
|
839
|
+
border-radius: 6px;
|
|
840
|
+
border: 1px solid rgba(255, 0, 0, 0.3);
|
|
841
|
+
}
|
|
842
|
+
`;
|
|
843
|
+
}
|