neoagent 1.6.0 → 2.0.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.
Files changed (62) hide show
  1. package/README.md +18 -4
  2. package/docs/configuration.md +2 -2
  3. package/docs/skills.md +1 -1
  4. package/lib/manager.js +64 -2
  5. package/package.json +9 -2
  6. package/server/config/origins.js +34 -0
  7. package/server/db/database.js +0 -13
  8. package/server/http/errors.js +17 -0
  9. package/server/http/middleware.js +81 -0
  10. package/server/http/routes.js +45 -0
  11. package/server/http/socket.js +23 -0
  12. package/server/http/static.js +50 -0
  13. package/server/index.js +50 -188
  14. package/server/public/.last_build_id +1 -0
  15. package/server/public/assets/AssetManifest.bin +1 -0
  16. package/server/public/assets/AssetManifest.bin.json +1 -0
  17. package/server/public/assets/AssetManifest.json +1 -0
  18. package/server/public/assets/FontManifest.json +1 -0
  19. package/server/public/assets/NOTICES +33454 -0
  20. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  21. package/server/public/assets/packages/cupertino_icons/assets/CupertinoIcons.ttf +0 -0
  22. package/server/public/assets/shaders/ink_sparkle.frag +126 -0
  23. package/server/public/assets/web/icons/Icon-192.png +0 -0
  24. package/server/public/canvaskit/canvaskit.js +192 -0
  25. package/server/public/canvaskit/canvaskit.js.symbols +12142 -0
  26. package/server/public/canvaskit/canvaskit.wasm +0 -0
  27. package/server/public/canvaskit/chromium/canvaskit.js +192 -0
  28. package/server/public/canvaskit/chromium/canvaskit.js.symbols +11106 -0
  29. package/server/public/canvaskit/chromium/canvaskit.wasm +0 -0
  30. package/server/public/canvaskit/skwasm.js +140 -0
  31. package/server/public/canvaskit/skwasm.js.symbols +12164 -0
  32. package/server/public/canvaskit/skwasm.wasm +0 -0
  33. package/server/public/canvaskit/skwasm_heavy.js +140 -0
  34. package/server/public/canvaskit/skwasm_heavy.js.symbols +13766 -0
  35. package/server/public/canvaskit/skwasm_heavy.wasm +0 -0
  36. package/server/public/favicon.png +0 -0
  37. package/server/public/flutter.js +32 -0
  38. package/server/public/flutter_bootstrap.js +43 -0
  39. package/server/public/flutter_service_worker.js +208 -0
  40. package/server/public/icons/Icon-192.png +0 -0
  41. package/server/public/icons/Icon-512.png +0 -0
  42. package/server/public/icons/Icon-maskable-192.png +0 -0
  43. package/server/public/icons/Icon-maskable-512.png +0 -0
  44. package/server/public/index.html +38 -0
  45. package/server/public/main.dart.js +103124 -0
  46. package/server/public/manifest.json +35 -0
  47. package/server/public/version.json +1 -0
  48. package/server/services/ai/models.js +2 -8
  49. package/server/services/ai/tools.js +0 -47
  50. package/server/services/browser/controller.js +34 -0
  51. package/server/services/manager.js +49 -118
  52. package/server/services/messaging/automation.js +210 -0
  53. package/server/utils/version.js +37 -0
  54. package/server/public/app.html +0 -682
  55. package/server/public/assets/world-office-dark.png +0 -0
  56. package/server/public/assets/world-office-light.png +0 -0
  57. package/server/public/css/app.css +0 -941
  58. package/server/public/css/styles.css +0 -963
  59. package/server/public/favicon.svg +0 -17
  60. package/server/public/js/app.js +0 -4105
  61. package/server/public/login.html +0 -313
  62. package/server/routes/protocols.js +0 -87
@@ -1,4105 +0,0 @@
1
- // ── NeoAgent App ──
2
-
3
- // Init mermaid if available
4
- if (window.mermaid) {
5
- mermaid.initialize({ startOnLoad: false, theme: "dark" });
6
- }
7
-
8
- // ── Theme (follows OS preference automatically) ──
9
-
10
- function applyTheme(isDark) {
11
- document.documentElement.dataset.theme = isDark ? "dark" : "light";
12
- if (window.mermaid) {
13
- mermaid.initialize({ startOnLoad: false, theme: isDark ? "dark" : "default" });
14
- }
15
- if (window.pixelWorld) {
16
- window.pixelWorld.syncTheme();
17
- }
18
- }
19
-
20
- const _mq = window.matchMedia("(prefers-color-scheme: dark)");
21
- applyTheme(_mq.matches);
22
- _mq.addEventListener("change", (e) => applyTheme(e.matches));
23
-
24
-
25
-
26
- // Global utility to re-run mermaid
27
- function renderMermaids() {
28
- if (window.mermaid) {
29
- try {
30
- mermaid.init(undefined, $$(".mermaid"));
31
- } catch (e) {
32
- console.error("Mermaid render error", e);
33
- }
34
- }
35
- }
36
-
37
- const socket = io();
38
- let isStreaming = false;
39
- const backgroundRunIds = new Set(); // tracks scheduler/heartbeat run IDs
40
-
41
- // ── Utility ──
42
-
43
- function $(sel) {
44
- return document.querySelector(sel);
45
- }
46
- function $$(sel) {
47
- return document.querySelectorAll(sel);
48
- }
49
-
50
- function toast(message, type = "info") {
51
- const container = $("#toasts");
52
- const el = document.createElement("div");
53
- el.className = `toast toast-${type}`;
54
- el.textContent = message;
55
- container.appendChild(el);
56
- setTimeout(() => el.remove(), 4000);
57
- }
58
-
59
- async function api(path, opts = {}) {
60
- const res = await fetch(`/api${path}`, {
61
- headers: { "Content-Type": "application/json", ...opts.headers },
62
- ...opts,
63
- body: opts.body ? JSON.stringify(opts.body) : undefined,
64
- });
65
-
66
- const contentType = (res.headers.get("content-type") || "").toLowerCase();
67
- let data = null;
68
- if (contentType.includes("application/json")) {
69
- data = await res.json();
70
- } else {
71
- const text = await res.text();
72
- data = { error: text || `Request failed (${res.status})` };
73
- }
74
-
75
- if (!res.ok) {
76
- const err = new Error(data?.error || `Request failed (${res.status})`);
77
- err.status = res.status;
78
- throw err;
79
- }
80
- return data || {};
81
- }
82
-
83
- function escapeHtml(str) {
84
- const div = document.createElement("div");
85
- div.textContent = str;
86
- return div.innerHTML;
87
- }
88
-
89
- function formatTime(ts) {
90
- return new Date(ts).toLocaleTimeString([], {
91
- hour: "2-digit",
92
- minute: "2-digit",
93
- });
94
- }
95
-
96
- // ── Navigation ──
97
-
98
- const DEFAULT_PAGE = "chat";
99
- const VALID_PAGES = new Set([
100
- "chat",
101
- "world",
102
- "messaging",
103
- "mcp",
104
- "scheduler",
105
- "memory",
106
- "skills",
107
- "protocols",
108
- "logs",
109
- ]);
110
-
111
- function getPageFromLocation() {
112
- const match = window.location.pathname.match(/^\/app\/([^/]+)$/);
113
- const candidate = match?.[1] || (window.location.pathname === "/app" ? DEFAULT_PAGE : null);
114
- return VALID_PAGES.has(candidate) ? candidate : DEFAULT_PAGE;
115
- }
116
-
117
- function buildPageUrl(page) {
118
- return page === DEFAULT_PAGE ? "/app" : `/app/${page}`;
119
- }
120
-
121
- function navigateTo(page, { push = true } = {}) {
122
- if (!VALID_PAGES.has(page)) page = DEFAULT_PAGE;
123
-
124
- $$(".page").forEach((p) => p.classList.remove("active"));
125
- $$(".sidebar-btn").forEach((b) => b.classList.remove("active"));
126
-
127
- const pageEl = $(`#page-${page}`);
128
- if (pageEl) {
129
- pageEl.classList.add("active");
130
- const btn = $(`.sidebar-btn[data-page="${page}"]`);
131
- if (btn) btn.classList.add("active");
132
- }
133
-
134
- if (push) {
135
- const nextUrl = buildPageUrl(page);
136
- if (window.location.pathname !== nextUrl) {
137
- window.history.pushState({ page }, "", nextUrl);
138
- }
139
- }
140
-
141
- if (page === "memory") loadMemoryPage();
142
- if (page === "skills") loadSkillsPage();
143
- if (page === "mcp") loadMCPPage();
144
- if (page === "scheduler") loadSchedulerPage();
145
- if (page === "messaging") loadMessagingPage();
146
- if (page === "protocols") loadProtocolsPage();
147
- if (page === "world") {
148
- requestAnimationFrame(() => {
149
- ensureWorld();
150
- if (pixelWorld) {
151
- pixelWorld.resize();
152
- pixelWorld.refreshSummary();
153
- }
154
- });
155
- }
156
- if (page === "logs") loadLogsPage();
157
- }
158
-
159
- $$(".sidebar-btn[data-page]").forEach((btn) => {
160
- btn.addEventListener("click", () => navigateTo(btn.dataset.page));
161
- });
162
-
163
- window.addEventListener("popstate", () => {
164
- navigateTo(getPageFromLocation(), { push: false });
165
- });
166
-
167
- // ── Chat ──
168
-
169
- const chatInput = $("#chatInput");
170
- const chatMessages = $("#chatMessages");
171
- const chatEmpty = $("#chatEmpty");
172
- const sendBtn = $("#chatSendBtn");
173
-
174
- chatInput.addEventListener("input", () => {
175
- chatInput.style.height = "auto";
176
- chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + "px";
177
- });
178
-
179
- chatInput.addEventListener("keydown", (e) => {
180
- if (e.key === "Enter" && !e.shiftKey) {
181
- e.preventDefault();
182
- sendMessage();
183
- }
184
- });
185
-
186
- sendBtn.addEventListener("click", sendMessage);
187
-
188
- function sendMessage() {
189
- const text = chatInput.value.trim();
190
- if (!text || isStreaming) return;
191
-
192
- chatEmpty.classList.add("hidden");
193
- appendMessage("user", text);
194
- chatInput.value = "";
195
- chatInput.style.height = "auto";
196
-
197
- isStreaming = true;
198
- sendBtn.disabled = true;
199
-
200
- const thinkingEl = document.createElement("div");
201
- thinkingEl.className = "chat-thinking";
202
- thinkingEl.id = "thinking";
203
- thinkingEl.innerHTML =
204
- '<div class="spinner"></div><span id="thinkingText">NeoAgent is thinking...</span>';
205
- chatMessages.appendChild(thinkingEl);
206
- chatMessages.scrollTop = chatMessages.scrollHeight;
207
-
208
- // Reset world focus for the incoming run
209
- resetWorldForNewRun();
210
-
211
- socket.emit("agent:run", { task: text });
212
- }
213
-
214
- function appendMessage(role, content) {
215
- const chunks = role === "assistant" ? content.split(/\n\n+/).filter(c => c.trim()) : [content];
216
- let firstBubble = null;
217
- for (const chunk of chunks) {
218
- const div = document.createElement("div");
219
- div.className = `chat-message ${role}`;
220
-
221
- const avatar = document.createElement("div");
222
- avatar.className = "chat-avatar";
223
- avatar.textContent = role === "user" ? "U" : "N";
224
-
225
- const bubble = document.createElement("div");
226
- bubble.className = "chat-bubble md-content";
227
- bubble.innerHTML = renderMarkdown(chunk);
228
- requestAnimationFrame(renderMermaids);
229
-
230
- div.appendChild(avatar);
231
- div.appendChild(bubble);
232
- chatMessages.appendChild(div);
233
- if (!firstBubble) firstBubble = bubble;
234
- }
235
- chatMessages.scrollTop = chatMessages.scrollHeight;
236
- return firstBubble;
237
- }
238
-
239
- function appendToolCall(name, args, result) {
240
- const div = document.createElement("div");
241
- div.className = "chat-message assistant";
242
- const avatar = document.createElement("div");
243
- avatar.className = "chat-avatar";
244
- avatar.textContent = "N";
245
- const bubble = document.createElement("div");
246
- bubble.className = "chat-bubble";
247
- bubble.innerHTML = `<div class="chat-tool-call"><div class="tool-name">${escapeHtml(name)}</div>${args ? `<div class="tool-result">${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2)).slice(0, 500)}</div>` : ""}${result ? `<div class="tool-result" style="border-top:1px solid var(--border);padding-top:6px;margin-top:6px;">${escapeHtml(typeof result === "string" ? result : JSON.stringify(result, null, 2)).slice(0, 1000)}</div>` : ""}</div>`;
248
- div.appendChild(avatar);
249
- div.appendChild(bubble);
250
- chatMessages.appendChild(div);
251
- chatMessages.scrollTop = chatMessages.scrollHeight;
252
- }
253
-
254
- // Status update (interim message from AI mid-task)
255
- function appendInterimMessage(message) {
256
- const div = document.createElement("div");
257
- div.className = "chat-interim";
258
- div.innerHTML = `<div class="chat-interim-dot"></div><div class="chat-interim-text">${escapeHtml(message)}</div>`;
259
- chatMessages.appendChild(div);
260
- chatMessages.scrollTop = chatMessages.scrollHeight;
261
- }
262
-
263
- // Show a social platform message (WhatsApp etc.) in chat
264
- function appendSocialMessage(platform, role, content, senderName) {
265
- const div = document.createElement("div");
266
- div.className =
267
- role === "user" ? "chat-message social" : "chat-message assistant";
268
- const avatar = document.createElement("div");
269
- avatar.className = "chat-avatar";
270
- avatar.textContent =
271
- platform === "whatsapp" ? "💬" : platform[0].toUpperCase();
272
- if (role === "user")
273
- avatar.style.cssText = "background:#25d36620;color:#25d366;font-size:12px;";
274
- const bubble = document.createElement("div");
275
- bubble.className = "chat-bubble";
276
- const badge = `<div class="chat-platform-badge ${platform.toLowerCase()}">${platform}</div>`;
277
- const sender =
278
- role === "user" && senderName
279
- ? `<div class="chat-sender">${escapeHtml(senderName)}</div>`
280
- : "";
281
- bubble.innerHTML = badge + sender + renderMarkdown(content);
282
- requestAnimationFrame(renderMermaids);
283
- div.appendChild(avatar);
284
- div.appendChild(bubble);
285
- chatMessages.appendChild(div);
286
- chatMessages.scrollTop = chatMessages.scrollHeight;
287
- }
288
-
289
- // Load and render chat history from DB
290
- async function loadChatHistory() {
291
- try {
292
- const data = await api("/agents/chat-history?limit=80");
293
- if (!data.messages || data.messages.length === 0) return;
294
- chatEmpty.classList.add("hidden");
295
- for (const msg of data.messages) {
296
- if (!msg.content) continue;
297
- if (msg.platform === "web") {
298
- appendMessage(msg.role, msg.content);
299
- } else {
300
- appendSocialMessage(
301
- msg.platform,
302
- msg.role,
303
- msg.content,
304
- msg.sender_name,
305
- );
306
- }
307
- }
308
- } catch {
309
- /* silently skip */
310
- }
311
- }
312
-
313
- // Load history on startup
314
- loadChatHistory();
315
-
316
- // Simple markdown renderer
317
- function renderMarkdown(text) {
318
- if (!text) return "";
319
- let html = escapeHtml(text);
320
-
321
- // Mermaid blocks
322
- html = html.replace(/```mermaid\n([\s\S]*?)```/g, (match, code) => {
323
- return `<div class="mermaid-container" style="background:#0f172a;padding:12px;border-radius:8px;margin:8px 0;"><pre class="mermaid">${code}</pre></div>`;
324
- });
325
-
326
- // Code blocks
327
- html = html.replace(/```(\w*)\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>");
328
- // Inline code
329
- html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
330
- // Bold
331
- html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
332
- // Italic
333
- html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
334
- // Headers
335
- html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
336
- html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
337
- html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
338
-
339
- // Tables
340
- html = html.replace(
341
- /\n\|?(.+)\|?\n\|?([-:| ]+)\|?\n((?:\|?.*\|?\n?)*)/g,
342
- (match, header, sub, body) => {
343
- if (!sub.includes("-")) return match;
344
- const thead =
345
- "<thead><tr>" +
346
- header
347
- .split("|")
348
- .filter((c) => c.trim())
349
- .map((c) => `<th>${c.trim()}</th>`)
350
- .join("") +
351
- "</tr></thead>";
352
- const tbody =
353
- "<tbody>" +
354
- body
355
- .trim()
356
- .split("\n")
357
- .map((row) => {
358
- const parts = row.split("|").filter((c) => c.trim() || c === "");
359
- if (parts.length === 0) return "";
360
- return (
361
- "<tr>" +
362
- parts.map((c) => `<td>${c.trim()}</td>`).join("") +
363
- "</tr>"
364
- );
365
- })
366
- .join("") +
367
- "</tbody>";
368
- return `\n<div class="table-responsive"><table class="md-table">${thead}${tbody}</table></div>\n`;
369
- },
370
- );
371
-
372
- // Lists
373
- html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
374
- html = html.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>");
375
- // Links
376
- html = html.replace(
377
- /\[(.+?)\]\((.+?)\)/g,
378
- '<a href="$2" target="_blank">$1</a>',
379
- );
380
- // Line breaks
381
- html = html.replace(
382
- /\n(?!(?:<\/th>|<\/tr>|<\/td>|<\/thead>|<\/tbody>|<\/table>|<\/div>|<div|<table|<thead|<tbody|<tr|<th|<td|<pre|<\/pre>|<ul|<\/ul>|<li|<\/li>))/g,
383
- "<br>",
384
- );
385
- return html;
386
- }
387
-
388
- // ── World Helpers ──
389
-
390
- const TOOL_META = {
391
- execute_command: { icon: "⚡", label: "Terminal", color: "cli" },
392
- browser_navigate: { icon: "🌐", label: "Browse", color: "browser" },
393
- browser_click: { icon: "🖱️", label: "Click", color: "browser" },
394
- browser_type: { icon: "⌨️", label: "Type", color: "browser" },
395
- browser_extract: { icon: "📋", label: "Extract", color: "browser" },
396
- browser_screenshot: { icon: "📷", label: "Screenshot", color: "browser" },
397
- browser_evaluate: { icon: "⚙️", label: "Script", color: "browser" },
398
- memory_write: { icon: "🧠", label: "Memory Write", color: "memory" },
399
- memory_read: { icon: "🧠", label: "Memory Read", color: "memory" },
400
- memory_save: { icon: "🧠", label: "Save Memory", color: "memory" },
401
- memory_recall: { icon: "🔍", label: "Recall Memory", color: "memory" },
402
- memory_update_core: { icon: "📌", label: "Core Memory", color: "memory" },
403
- think: { icon: "💭", label: "Thinking", color: "thinking" },
404
- send_message: { icon: "💬", label: "Message", color: "messaging" },
405
- make_call: { icon: "📞", label: "Call", color: "messaging" },
406
- http_request: { icon: "🔗", label: "HTTP Request", color: "http" },
407
- read_file: { icon: "📄", label: "Read File", color: "file" },
408
- write_file: { icon: "📝", label: "Write File", color: "file" },
409
- list_directory: { icon: "📁", label: "List Dir", color: "file" },
410
- spawn_subagent: { icon: "🤖", label: "Sub-Agent", color: "agent" },
411
- };
412
-
413
- function getToolMeta(name) {
414
- return TOOL_META[name] || { icon: "🔧", label: name, color: "tool" };
415
- }
416
-
417
- function describeArgs(toolName, args) {
418
- if (!args) return null;
419
- switch (toolName) {
420
- case "execute_command":
421
- return {
422
- headline: args.command,
423
- detail: args.cwd ? `Dir: ${args.cwd}` : null,
424
- };
425
- case "browser_navigate":
426
- return { headline: args.url };
427
- case "browser_click":
428
- return {
429
- headline: args.text ? `"${args.text}"` : args.selector || "element",
430
- };
431
- case "browser_type":
432
- return { headline: `"${args.text}"`, detail: `into ${args.selector}` };
433
- case "browser_screenshot":
434
- return {
435
- headline: args.selector ? `Element: ${args.selector}` : "Full page",
436
- };
437
- case "browser_extract":
438
- return { headline: args.selector || "Page content" };
439
- case "browser_evaluate":
440
- return { headline: args.script?.slice(0, 120) };
441
- case "memory_write":
442
- return {
443
- headline: `→ ${args.target}`,
444
- detail: args.content?.slice(0, 160),
445
- };
446
- case "memory_read":
447
- return {
448
- headline: `← ${args.target}`,
449
- detail: args.search ? `Search: "${args.search}"` : null,
450
- };
451
- case "memory_save":
452
- return {
453
- headline: args.content?.slice(0, 200),
454
- detail: `${args.category || "episodic"} · importance ${args.importance || 5}`,
455
- };
456
- case "memory_recall":
457
- return {
458
- headline: `"${args.query}"`,
459
- detail: args.limit ? `top ${args.limit}` : null,
460
- };
461
- case "memory_update_core":
462
- return {
463
- headline: `${args.key} → ${String(args.value || "").slice(0, 100)}`,
464
- };
465
- case "think":
466
- return { headline: args.thought?.slice(0, 400) };
467
- case "http_request":
468
- return { headline: `${args.method || "GET"} ${args.url}` };
469
- case "send_message":
470
- return {
471
- headline: args.content?.slice(0, 160),
472
- detail: `${args.platform} → ${args.to}`,
473
- };
474
- case "make_call":
475
- return {
476
- headline: `Calling ${args.to}`,
477
- detail: args.greeting?.slice(0, 100),
478
- };
479
- case "read_file":
480
- return { headline: args.path };
481
- case "write_file":
482
- return {
483
- headline: args.path,
484
- detail: `${(args.content || "").length} chars`,
485
- };
486
- case "list_directory":
487
- return { headline: args.path };
488
- case "spawn_subagent":
489
- return { headline: args.task?.slice(0, 200) };
490
- default: {
491
- const first = Object.values(args).find((v) => typeof v === "string");
492
- return first ? { headline: first.slice(0, 160) } : null;
493
- }
494
- }
495
- }
496
-
497
- function describeResult(toolName, result) {
498
- if (!result) return null;
499
- if (result.error) return { type: "error", text: result.error };
500
- switch (toolName) {
501
- case "execute_command": {
502
- const out = (
503
- result.stdout ||
504
- result.output ||
505
- result.stderr ||
506
- ""
507
- ).trim();
508
- const code = result.exitCode ?? result.exit_code;
509
- return {
510
- type: code === 0 || code == null ? "code" : "error",
511
- text: out.slice(0, 600) || "(no output)",
512
- meta: code != null ? `Exit ${code}` : null,
513
- };
514
- }
515
- case "browser_navigate":
516
- case "browser_click":
517
- case "browser_type":
518
- case "browser_screenshot":
519
- case "browser_evaluate":
520
- return { type: "screenshot", meta: result.title || null };
521
- case "memory_write":
522
- return { type: "success", text: "Saved ✓" };
523
- case "memory_read": {
524
- const txt =
525
- typeof result === "string"
526
- ? result
527
- : result.content || JSON.stringify(result);
528
- return { type: "output", text: txt.slice(0, 400) };
529
- }
530
- case "memory_save":
531
- return { type: "success", text: "Saved to memory ✓" };
532
- case "memory_update_core":
533
- return { type: "success", text: "Core memory updated ✓" };
534
- case "memory_recall": {
535
- const results = result?.results || [];
536
- if (!results.length) return { type: "output", text: "Nothing found" };
537
- const preview = results
538
- .slice(0, 3)
539
- .map((r) => `• ${r.content}`)
540
- .join("\n");
541
- return { type: "output", text: preview };
542
- }
543
- case "think":
544
- return null;
545
- case "http_request": {
546
- const s = result.status;
547
- const cls = s >= 200 && s < 300 ? "ok" : s >= 400 ? "err" : "warn";
548
- return {
549
- type: "output",
550
- text: (result.body || "").slice(0, 400),
551
- meta: `HTTP ${s}`,
552
- statusClass: cls,
553
- };
554
- }
555
- case "read_file":
556
- return { type: "code", text: (result.content || "").slice(0, 400) };
557
- case "write_file":
558
- return { type: "success", text: "File written ✓" };
559
- case "list_directory": {
560
- const items = (result.entries || [])
561
- .slice(0, 20)
562
- .map((e) => e.name || e)
563
- .join("\n");
564
- return { type: "code", text: items };
565
- }
566
- default: {
567
- const txt =
568
- typeof result === "string" ? result : JSON.stringify(result, null, 2);
569
- return { type: "output", text: txt.slice(0, 400) };
570
- }
571
- }
572
- }
573
-
574
- class PixelWorld {
575
- constructor(canvas) {
576
- this.canvas = canvas;
577
- this.ctx = canvas.getContext("2d");
578
- this.buffer = document.createElement("canvas");
579
- this.buffer.width = 384;
580
- this.buffer.height = 216;
581
- this.bctx = this.buffer.getContext("2d");
582
- this.dpr = Math.max(1, window.devicePixelRatio || 1);
583
- this.tick = 0;
584
- this.runId = null;
585
- this.runMode = "idle";
586
- this.activeTool = null;
587
- this.taskLabel = "No active run";
588
- this.statusLabel = "Ambient systems nominal";
589
- this.totalTools = 0;
590
- this.totalMessages = 0;
591
- this.helperCounter = 0;
592
- this.scanFlash = 0;
593
- this.socialPulse = 0;
594
- this.errorFlash = 0;
595
- this.recentEvents = [];
596
- this.historyLoaded = false;
597
- this.stepAssignments = new Map();
598
- this.structures = [
599
- { key: "core", x: 138, y: 78, w: 104, h: 64, color: "#22c55e", glow: 0, label: "Lead Desk" },
600
- { key: "browser", x: 20, y: 16, w: 96, h: 80, color: "#3b82f6", glow: 0, label: "Research Corner" },
601
- { key: "memory", x: 286, y: 18, w: 82, h: 78, color: "#f59e0b", glow: 0, label: "Archive Wall" },
602
- { key: "cli", x: 16, y: 108, w: 108, h: 84, color: "#f97316", glow: 0, label: "Ops Bench" },
603
- { key: "social", x: 288, y: 116, w: 80, h: 74, color: "#ec4899", glow: 0, label: "Comms Desk" },
604
- ];
605
- this.helperSlots = [
606
- { x: 134, y: 160 },
607
- { x: 250, y: 160 },
608
- { x: 164, y: 176 },
609
- { x: 222, y: 176 },
610
- ];
611
- this.mainAgent = {
612
- id: "lead-agent",
613
- name: "NeoAgent",
614
- type: "lead",
615
- x: 192,
616
- y: 104,
617
- tint: "#9ef7dc",
618
- phase: 0.8,
619
- focus: "core",
620
- specialty: "Orchestrating the whole task",
621
- status: "Waiting for the next task",
622
- lastActive: 0,
623
- };
624
- this.helpers = [];
625
- this.packets = [];
626
- this.palette = null;
627
- this.officeImages = {
628
- dark: this.loadImage("/assets/world-office-dark.png"),
629
- light: this.loadImage("/assets/world-office-light.png"),
630
- };
631
- this.ui = {
632
- modePill: $("#worldModePill"),
633
- toolPill: $("#worldToolPill"),
634
- task: $("#worldTaskValue"),
635
- status: $("#worldStatusValue"),
636
- mode: $("#worldModeValue"),
637
- run: $("#worldRunValue"),
638
- tools: $("#worldToolsValue"),
639
- helpers: $("#worldHelpersValue"),
640
- messages: $("#worldMessagesValue"),
641
- agents: $("#worldAgentList"),
642
- events: $("#worldEventList"),
643
- badge: $("#worldBadge"),
644
- };
645
-
646
- this.resize = this.resize.bind(this);
647
- this.loop = this.loop.bind(this);
648
- window.addEventListener("resize", this.resize);
649
- this.syncTheme();
650
- this.resize();
651
- this.renderAgents();
652
- this.renderEventList();
653
- this.renderHud();
654
- requestAnimationFrame(this.loop);
655
- }
656
-
657
- resize() {
658
- const rect = this.canvas.getBoundingClientRect();
659
- const width = Math.max(320, Math.floor(rect.width || this.canvas.clientWidth || 640));
660
- const height = Math.max(320, Math.floor(rect.height || this.canvas.clientHeight || 560));
661
- this.canvas.width = Math.floor(width * this.dpr);
662
- this.canvas.height = Math.floor(height * this.dpr);
663
- this.ctx.setTransform(1, 0, 0, 1, 0, 0);
664
- this.ctx.imageSmoothingEnabled = false;
665
- }
666
-
667
- syncTheme() {
668
- const styles = getComputedStyle(document.documentElement);
669
- const isDark = document.documentElement.dataset.theme !== "light";
670
- this.palette = {
671
- isDark,
672
- bg0: styles.getPropertyValue("--bg-0").trim(),
673
- bg1: styles.getPropertyValue("--bg-1").trim(),
674
- bg2: styles.getPropertyValue("--bg-2").trim(),
675
- bg3: styles.getPropertyValue("--bg-3").trim(),
676
- text: styles.getPropertyValue("--text-primary").trim(),
677
- muted: styles.getPropertyValue("--text-muted").trim(),
678
- border: styles.getPropertyValue("--border").trim(),
679
- accent: styles.getPropertyValue("--accent").trim(),
680
- success: styles.getPropertyValue("--success").trim(),
681
- info: styles.getPropertyValue("--info").trim(),
682
- warning: styles.getPropertyValue("--warning").trim(),
683
- error: styles.getPropertyValue("--error").trim(),
684
- floor: isDark ? "#252b31" : "#eceff2",
685
- floorAlt: isDark ? "#2e353d" : "#dfe5ea",
686
- wall: isDark ? "#151a1f" : "#fcfdff",
687
- trim: isDark ? "#0d1115" : "#cfd6de",
688
- desk: isDark ? "#4b5563" : "#d1d8e0",
689
- deskTop: isDark ? "#667181" : "#e0e5eb",
690
- chair: isDark ? "#1b232c" : "#8f9bab",
691
- screen: isDark ? "#111827" : "#eff6ff",
692
- screenGlow: isDark ? "#60a5fa" : "#2563eb",
693
- glass: isDark ? "rgba(120, 162, 219, 0.18)" : "rgba(134, 189, 255, 0.28)",
694
- plant: isDark ? "#4ca56f" : "#5fba7f",
695
- plantDark: isDark ? "#2a6d48" : "#438e5f",
696
- shadow: isDark ? "rgba(0,0,0,0.26)" : "rgba(59,76,94,0.10)",
697
- paper: isDark ? "#dbeafe" : "#ffffff",
698
- rug: isDark ? "#2f3640" : "#dde4eb",
699
- rugLine: isDark ? "#3d4754" : "#c7d1db",
700
- wood: isDark ? "#705038" : "#c28d62",
701
- coffee: isDark ? "#2b211d" : "#6b4b38",
702
- };
703
- }
704
-
705
- loadImage(src) {
706
- const img = new Image();
707
- img.src = src;
708
- return img;
709
- }
710
-
711
- refreshSummary() {
712
- if (this.historyLoaded) return;
713
- api("/agents?limit=6")
714
- .then((data) => {
715
- this.historyLoaded = true;
716
- const runs = data.runs || [];
717
- if (!runs.length || this.runMode !== "idle") return;
718
- this.pushEvent("history", `${runs.length} recent runs archived in the background.`);
719
- })
720
- .catch(() => {});
721
- }
722
-
723
- resetForNewRun() {
724
- this.runId = null;
725
- this.runMode = "idle";
726
- this.activeTool = null;
727
- this.taskLabel = "No active run";
728
- this.statusLabel = "Ambient systems nominal";
729
- this.totalTools = 0;
730
- this.stepAssignments.clear();
731
- this.helpers = [];
732
- this.packets.length = 0;
733
- this.scanFlash = 0;
734
- this.errorFlash = 0;
735
- this.mainAgent.focus = "core";
736
- this.mainAgent.status = "Waiting for the next task";
737
- this.mainAgent.lastActive = this.tick;
738
- this.renderAgents();
739
- this.renderHud();
740
- }
741
-
742
- onRunStart(data) {
743
- this.runId = data.runId;
744
- this.runMode = "running";
745
- this.activeTool = "Boot sequence";
746
- this.scanFlash = 1;
747
- this.mainAgent.status = "Welcoming a new task";
748
- this.mainAgent.lastActive = this.tick;
749
- this.pushEvent("run", `${data.title || `Run ${data.runId}`} is now live.`);
750
- this.flashStructure("core", 1.2);
751
- this.renderAgents();
752
- this.renderHud(data.title || `Run ${data.runId}`, "NeoAgent is getting everything set up");
753
- }
754
-
755
- onThinking(data) {
756
- this.runMode = "running";
757
- this.activeTool = `Thinking step ${data.iteration}`;
758
- this.scanFlash = Math.min(1.4, this.scanFlash + 0.18);
759
- this.mainAgent.status = `Thinking through step ${data.iteration}`;
760
- this.mainAgent.lastActive = this.tick;
761
- this.pushEvent("think", `NeoAgent is thinking through step ${data.iteration}.`);
762
- this.renderAgents();
763
- this.renderHud(undefined, "NeoAgent is planning the next move");
764
- }
765
-
766
- onToolStart(data) {
767
- this.runMode = "running";
768
- this.activeTool = getToolMeta(data.toolName).label;
769
- this.totalTools += 1;
770
- const structureKey = this.getStructureForTool(data.toolName);
771
- const actor = this.assignActor(data.stepId, data.toolName, data.toolArgs, structureKey);
772
- const target = this.getStructure(structureKey);
773
- this.spawnPacket(actor, target, target.color, data.toolName);
774
- this.flashStructure(structureKey, 1.4);
775
- this.pushEvent("tool", `${actor.name} is using ${getToolMeta(data.toolName).label.toLowerCase()} for ${this.getShortToolText(data.toolName, data.toolArgs)}.`);
776
- this.renderAgents();
777
- this.renderHud(undefined, `${actor.name} is working through ${target.label}`);
778
- }
779
-
780
- onToolEnd(data) {
781
- const structureKey = this.getStructureForTool(data.toolName);
782
- const actor = this.resolveActorForStep(data.stepId);
783
- this.flashStructure(structureKey, data.status === "failed" ? 1.8 : 0.9);
784
- if (data.status === "failed") {
785
- this.runMode = "failed";
786
- this.errorFlash = 1;
787
- actor.status = "Hit an issue and is regrouping";
788
- actor.lastActive = this.tick;
789
- this.pushEvent("fault", `${actor.name} hit a snag while using ${getToolMeta(data.toolName).label.toLowerCase()}.`);
790
- this.renderHud(undefined, `${actor.name} ran into an error`);
791
- } else {
792
- actor.status = `Wrapped up ${getToolMeta(data.toolName).label.toLowerCase()}`;
793
- actor.lastActive = this.tick;
794
- this.pushEvent("sync", `${actor.name} finished ${getToolMeta(data.toolName).label.toLowerCase()} cleanly.`);
795
- this.renderHud(undefined, `${actor.name} finished successfully`);
796
- }
797
- this.renderAgents();
798
- this.stepAssignments.delete(data.stepId);
799
- }
800
-
801
- onRunComplete(data) {
802
- this.runMode = data.status === "failed" ? "failed" : "completed";
803
- this.activeTool = data.status === "failed" ? "Recovery" : "Cooling down";
804
- this.stepAssignments.clear();
805
- this.mainAgent.status =
806
- this.runMode === "failed" ? "Comforting the crew and recovering" : "Wrapping up with the crew";
807
- this.mainAgent.lastActive = this.tick;
808
- for (const helper of this.helpers) {
809
- helper.status =
810
- this.runMode === "failed" ? "Standing by for retry" : "Heading back after helping";
811
- }
812
- this.flashStructure("core", this.runMode === "failed" ? 1.6 : 1.2);
813
- this.pushEvent(
814
- this.runMode === "failed" ? "fault" : "done",
815
- data.content ? data.content.slice(0, 90) : this.runMode === "failed" ? "The team is recovering from a failed run." : "The team finished the run."
816
- );
817
- this.renderAgents();
818
- this.renderHud(undefined, this.runMode === "failed" ? "The crew is recovering from a rough run" : "The crew wrapped everything up nicely");
819
- }
820
-
821
- onRunError(data) {
822
- this.runMode = "failed";
823
- this.activeTool = "Recovery";
824
- this.errorFlash = 1.1;
825
- this.mainAgent.status = "Helping the crew recover";
826
- for (const helper of this.helpers) {
827
- helper.status = "Waiting for new instructions";
828
- }
829
- this.flashStructure("core", 1.6);
830
- this.pushEvent("fault", data.error || "Unknown run error");
831
- this.renderAgents();
832
- this.renderHud(undefined, data.error || "The crew hit an unexpected error");
833
- }
834
-
835
- onMessage(data) {
836
- this.totalMessages += 1;
837
- this.socialPulse = 1.2;
838
- this.flashStructure("social", 1.5);
839
- const actor = this.helpers.find((helper) => helper.focus === "social") || this.mainAgent;
840
- actor.status = "Greeting a new incoming message";
841
- actor.lastActive = this.tick;
842
- const target = this.getStructure("social");
843
- this.spawnPacket(actor, target, "#ff7db7", "message");
844
- this.pushEvent("msg", `${actor.name} noticed a ${data.platform} message: ${String(data.content || "").slice(0, 72)}`);
845
- this.renderAgents();
846
- this.renderHud(undefined, "A friendly ping just reached the message port");
847
- }
848
-
849
- getShortToolText(toolName, toolArgs) {
850
- const desc = describeArgs(toolName, toolArgs);
851
- return desc?.headline ? desc.headline.slice(0, 58) : "signal received";
852
- }
853
-
854
- getStructureForTool(toolName) {
855
- if (toolName.startsWith("browser_")) return "browser";
856
- if (toolName.startsWith("memory_")) return "memory";
857
- if (toolName === "execute_command") return "cli";
858
- if (toolName === "send_message" || toolName === "make_call") return "social";
859
- return "core";
860
- }
861
-
862
- getStructure(key) {
863
- return this.structures.find((item) => item.key === key) || this.structures[0];
864
- }
865
-
866
- assignActor(stepId, toolName, toolArgs, structureKey) {
867
- if (toolName === "spawn_subagent") {
868
- const helper = this.spawnHelper(toolArgs, structureKey);
869
- this.stepAssignments.set(stepId, helper.id);
870
- this.mainAgent.status = `Delegating work to ${helper.name}`;
871
- this.mainAgent.lastActive = this.tick;
872
- return helper;
873
- }
874
-
875
- const specialist = this.helpers
876
- .filter((helper) => helper.focus === structureKey)
877
- .sort((a, b) => a.lastActive - b.lastActive)[0];
878
- const actor = specialist || this.mainAgent;
879
- actor.focus = structureKey;
880
- actor.status = this.describeFriendlyAction(toolName, toolArgs);
881
- actor.lastActive = this.tick;
882
- this.stepAssignments.set(stepId, actor.id);
883
- return actor;
884
- }
885
-
886
- resolveActorForStep(stepId) {
887
- const actorId = this.stepAssignments.get(stepId);
888
- if (!actorId || actorId === this.mainAgent.id) return this.mainAgent;
889
- return this.helpers.find((helper) => helper.id === actorId) || this.mainAgent;
890
- }
891
-
892
- spawnHelper(toolArgs, structureKey) {
893
- const slot = this.helperSlots[this.helpers.length % this.helperSlots.length];
894
- this.helperCounter += 1;
895
- const specialty = this.inferHelperSpecialty(toolArgs, structureKey);
896
- const focus = this.inferHelperFocus(toolArgs, structureKey);
897
- const helper = {
898
- id: `helper-${this.helperCounter}`,
899
- name: `Scout-${this.helperCounter}`,
900
- type: "helper",
901
- x: slot.x,
902
- y: slot.y,
903
- tint: ["#9fd6ff", "#ffd36b", "#ff9dce", "#cdb8ff"][this.helperCounter % 4],
904
- phase: 0.5 + this.helperCounter,
905
- focus,
906
- specialty,
907
- status: `Joining the task to help with ${specialty.toLowerCase()}`,
908
- lastActive: this.tick,
909
- };
910
- this.helpers.push(helper);
911
- this.spawnPacket(this.mainAgent, helper, helper.tint, "delegate");
912
- this.pushEvent("team", `NeoAgent spawned ${helper.name} to help with ${specialty.toLowerCase()}.`);
913
- return helper;
914
- }
915
-
916
- inferHelperSpecialty(toolArgs, structureKey) {
917
- const headline =
918
- toolArgs?.task ||
919
- toolArgs?.prompt ||
920
- toolArgs?.description ||
921
- toolArgs?.content ||
922
- this.getStructure(structureKey).label;
923
- return String(headline).slice(0, 36);
924
- }
925
-
926
- inferHelperFocus(toolArgs, fallback) {
927
- const text = JSON.stringify(toolArgs || {}).toLowerCase();
928
- if (text.includes("browser") || text.includes("web") || text.includes("search") || text.includes("page")) return "browser";
929
- if (text.includes("memory") || text.includes("recall") || text.includes("history")) return "memory";
930
- if (text.includes("command") || text.includes("shell") || text.includes("terminal") || text.includes("file")) return "cli";
931
- if (text.includes("message") || text.includes("call") || text.includes("email")) return "social";
932
- return fallback;
933
- }
934
-
935
- describeFriendlyAction(toolName, toolArgs) {
936
- const headline = this.getShortToolText(toolName, toolArgs);
937
- if (toolName === "execute_command") return `Checking the command forge for ${headline}`;
938
- if (toolName.startsWith("browser_")) return `Exploring the web for ${headline}`;
939
- if (toolName.startsWith("memory_")) return `Digging through memory for ${headline}`;
940
- if (toolName === "send_message" || toolName === "make_call") return `Reaching out about ${headline}`;
941
- return `Working on ${headline}`;
942
- }
943
-
944
- spawnPacket(bot, structure, color, label) {
945
- const targetX = structure.x + Math.floor((structure.w || 2) / 2);
946
- const targetY = structure.y + ((structure.h || 2) > 2 ? 4 : 0);
947
- this.packets.push({
948
- x: bot.x,
949
- y: bot.y - 4,
950
- fromX: bot.x,
951
- fromY: bot.y - 4,
952
- toX: targetX,
953
- toY: targetY,
954
- color,
955
- label,
956
- progress: 0,
957
- speed: 0.018 + Math.random() * 0.018,
958
- });
959
- }
960
-
961
- flashStructure(key, amount) {
962
- const structure = this.getStructure(key);
963
- structure.glow = Math.max(structure.glow, amount);
964
- }
965
-
966
- pushEvent(tag, text) {
967
- this.recentEvents.unshift({
968
- tag,
969
- text,
970
- time: new Date(),
971
- });
972
- this.recentEvents = this.recentEvents.slice(0, 6);
973
- if (this.ui.badge) this.ui.badge.classList.remove("hidden");
974
- this.renderEventList();
975
- }
976
-
977
- renderAgents() {
978
- const list = this.ui.agents;
979
- if (!list) return;
980
- const roster = [this.mainAgent, ...this.helpers];
981
- list.innerHTML = roster
982
- .map((agent) => `
983
- <div class="world-agent-card">
984
- <div class="world-agent-topline">
985
- <span class="world-agent-title">${escapeHtml(agent.name)}</span>
986
- <span class="world-agent-chip ${agent.type === "lead" ? "lead" : "helper"}">${agent.type === "lead" ? "Lead" : "Helper"}</span>
987
- </div>
988
- <div class="world-agent-meta">${escapeHtml(agent.specialty)}</div>
989
- <div class="world-agent-status">${escapeHtml(agent.status)}</div>
990
- </div>
991
- `)
992
- .join("");
993
- }
994
-
995
- renderEventList() {
996
- const list = this.ui.events;
997
- if (!list) return;
998
- if (!this.recentEvents.length) {
999
- list.innerHTML = '<div class="world-empty-state">The world is idling. Start a task in chat to wake everything up.</div>';
1000
- return;
1001
- }
1002
- list.innerHTML = this.recentEvents
1003
- .map((event) => `
1004
- <div class="world-event-entry">
1005
- <div class="world-event-topline">
1006
- <span class="world-event-tag">${escapeHtml(event.tag)}</span>
1007
- <span class="world-event-time">${escapeHtml(formatTime(event.time))}</span>
1008
- </div>
1009
- <div class="world-event-text">${escapeHtml(event.text)}</div>
1010
- </div>
1011
- `)
1012
- .join("");
1013
- }
1014
-
1015
- renderHud(taskText, statusText) {
1016
- if (taskText) this.taskLabel = taskText;
1017
- if (statusText) this.statusLabel = statusText;
1018
- const modeText =
1019
- this.runMode === "running"
1020
- ? "Running"
1021
- : this.runMode === "completed"
1022
- ? "Complete"
1023
- : this.runMode === "failed"
1024
- ? "Fault"
1025
- : "Idle";
1026
- if (this.ui.modePill) this.ui.modePill.textContent = modeText;
1027
- if (this.ui.toolPill) this.ui.toolPill.textContent = this.activeTool || "Awaiting signal";
1028
- if (this.ui.task) this.ui.task.textContent = this.taskLabel || (this.runId ? `Run ${this.runId}` : "No active run");
1029
- if (this.ui.status) this.ui.status.textContent = this.statusLabel || this.getAmbientStatus();
1030
- if (this.ui.mode) this.ui.mode.textContent = modeText;
1031
- if (this.ui.run) this.ui.run.textContent = this.runId ? String(this.runId) : "None";
1032
- if (this.ui.tools) this.ui.tools.textContent = String(this.totalTools);
1033
- if (this.ui.helpers) this.ui.helpers.textContent = String(this.helpers.length);
1034
- if (this.ui.messages) this.ui.messages.textContent = String(this.totalMessages);
1035
- }
1036
-
1037
- getAmbientStatus() {
1038
- if (this.runMode === "running") return "The office is busy and everyone is moving work forward";
1039
- if (this.runMode === "completed") return "The office settled down after a clean handoff";
1040
- if (this.runMode === "failed") return "The team is regrouping after a rough patch";
1041
- return "The office is quiet and ready for the next task";
1042
- }
1043
-
1044
- loop() {
1045
- this.tick += 1;
1046
- this.updateSimulation();
1047
- this.draw();
1048
- requestAnimationFrame(this.loop);
1049
- }
1050
-
1051
- updateSimulation() {
1052
- this.scanFlash *= 0.97;
1053
- this.socialPulse *= 0.94;
1054
- this.errorFlash *= 0.93;
1055
-
1056
- for (const structure of this.structures) {
1057
- structure.glow *= 0.94;
1058
- }
1059
-
1060
- this.mainAgent.phase += 0.025;
1061
- for (const helper of this.helpers) helper.phase += 0.025;
1062
-
1063
- this.packets = this.packets.filter((packet) => {
1064
- packet.progress += packet.speed;
1065
- packet.x = packet.fromX + (packet.toX - packet.fromX) * packet.progress;
1066
- packet.y = packet.fromY + (packet.toY - packet.fromY) * packet.progress - Math.sin(packet.progress * Math.PI) * 10;
1067
- return packet.progress < 1;
1068
- });
1069
- }
1070
-
1071
- draw() {
1072
- const ctx = this.bctx;
1073
- const t = this.tick;
1074
- ctx.clearRect(0, 0, 384, 216);
1075
- this.drawOffice(ctx);
1076
- this.drawStructures(ctx);
1077
- this.drawWalkways(ctx);
1078
- this.drawBots(ctx, t);
1079
- this.drawPackets(ctx);
1080
- this.drawStatusEffects(ctx, t);
1081
- this.drawScanlines(ctx);
1082
-
1083
- this.ctx.save();
1084
- this.ctx.setTransform(1, 0, 0, 1, 0, 0);
1085
- this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1086
- this.ctx.imageSmoothingEnabled = false;
1087
- this.ctx.drawImage(this.buffer, 0, 0, this.canvas.width, this.canvas.height);
1088
- this.ctx.restore();
1089
- }
1090
-
1091
- drawOffice(ctx) {
1092
- const ref = this.palette.isDark ? this.officeImages.dark : this.officeImages.light;
1093
- if (ref && ref.complete && ref.naturalWidth > 0) {
1094
- this.drawReferenceImage(ctx, ref, 384, 216);
1095
- return;
1096
- }
1097
-
1098
- const p = this.palette;
1099
- ctx.fillStyle = p.floor;
1100
- ctx.fillRect(0, 0, 384, 216);
1101
-
1102
- ctx.fillStyle = p.floorAlt;
1103
- for (let y = 0; y < 216; y += 24) {
1104
- for (let x = (y / 24) % 2 === 0 ? 0 : 12; x < 384; x += 24) {
1105
- ctx.fillRect(x, y, 12, 12);
1106
- }
1107
- }
1108
-
1109
- ctx.fillStyle = p.wall;
1110
- ctx.fillRect(12, 12, 360, 10);
1111
- ctx.fillRect(12, 194, 360, 10);
1112
- ctx.fillRect(12, 22, 10, 172);
1113
- ctx.fillRect(362, 22, 10, 172);
1114
- ctx.fillStyle = p.trim;
1115
- ctx.fillRect(22, 22, 340, 2);
1116
- ctx.fillRect(22, 192, 340, 2);
1117
- ctx.fillRect(22, 22, 2, 170);
1118
- ctx.fillRect(360, 22, 2, 170);
1119
-
1120
- ctx.fillStyle = p.glass;
1121
- this.drawWindow(ctx, 118, 22, 62, 10);
1122
- this.drawWindow(ctx, 190, 22, 62, 10);
1123
-
1124
- ctx.fillStyle = p.rug;
1125
- ctx.fillRect(134, 68, 116, 74);
1126
- ctx.fillStyle = p.rugLine;
1127
- for (let x = 140; x < 240; x += 12) ctx.fillRect(x, 74, 2, 62);
1128
-
1129
- this.drawMeetingTable(ctx, 146, 80);
1130
- this.drawShelfWall(ctx, 34, 34);
1131
- this.drawShelfWall(ctx, 318, 34);
1132
- this.drawCoffeeBar(ctx, 300, 150);
1133
- this.drawPrinterNook(ctx, 34, 150);
1134
- this.drawLounge(ctx, 168, 150);
1135
-
1136
- this.drawPlant(ctx, 30, 30);
1137
- this.drawPlant(ctx, 346, 30);
1138
- this.drawPlant(ctx, 30, 178);
1139
- this.drawPlant(ctx, 346, 178);
1140
-
1141
- this.drawDeskCluster(ctx, 154, 80, "core");
1142
- this.drawDeskCluster(ctx, 270, 42, "browser");
1143
- this.drawDeskCluster(ctx, 56, 42, "memory");
1144
- this.drawDeskCluster(ctx, 56, 146, "cli");
1145
- this.drawDeskCluster(ctx, 270, 146, "social");
1146
- }
1147
-
1148
- drawReferenceImage(ctx, image, targetWidth, targetHeight) {
1149
- const imageRatio = image.naturalWidth / image.naturalHeight;
1150
- const targetRatio = targetWidth / targetHeight;
1151
-
1152
- let drawWidth = targetWidth;
1153
- let drawHeight = targetHeight;
1154
- let offsetX = 0;
1155
- let offsetY = 0;
1156
-
1157
- if (imageRatio > targetRatio) {
1158
- drawWidth = targetWidth;
1159
- drawHeight = Math.round(targetWidth / imageRatio);
1160
- offsetY = Math.floor((targetHeight - drawHeight) / 2);
1161
- } else {
1162
- drawHeight = targetHeight;
1163
- drawWidth = Math.round(targetHeight * imageRatio);
1164
- offsetX = Math.floor((targetWidth - drawWidth) / 2);
1165
- }
1166
-
1167
- ctx.fillStyle = this.palette.floor;
1168
- ctx.fillRect(0, 0, targetWidth, targetHeight);
1169
- ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
1170
- }
1171
-
1172
- drawPlant(ctx, x, y) {
1173
- const p = this.palette;
1174
- ctx.fillStyle = p.wood;
1175
- ctx.fillRect(x, y + 8, 8, 7);
1176
- ctx.fillStyle = p.plantDark;
1177
- ctx.fillRect(x - 2, y + 2, 12, 8);
1178
- ctx.fillStyle = p.plant;
1179
- ctx.fillRect(x - 4, y, 16, 7);
1180
- }
1181
-
1182
- drawWindow(ctx, x, y, w, h) {
1183
- const p = this.palette;
1184
- ctx.fillStyle = p.trim;
1185
- ctx.fillRect(x, y, w, h);
1186
- ctx.fillStyle = p.glass;
1187
- ctx.fillRect(x + 2, y + 2, w - 4, h - 4);
1188
- ctx.fillStyle = this.hexToRgba("#ffffff", p.isDark ? 0.12 : 0.3);
1189
- ctx.fillRect(x + 8, y + 2, 2, h - 4);
1190
- ctx.fillRect(x + 24, y + 2, 2, h - 4);
1191
- ctx.fillRect(x + 40, y + 2, 2, h - 4);
1192
- }
1193
-
1194
- drawMeetingTable(ctx, x, y) {
1195
- const p = this.palette;
1196
- ctx.fillStyle = p.shadow;
1197
- ctx.fillRect(x + 4, y + 38, 92, 4);
1198
- ctx.fillStyle = p.wood;
1199
- ctx.fillRect(x, y, 100, 38);
1200
- ctx.fillStyle = p.paper;
1201
- ctx.fillRect(x + 12, y + 10, 18, 10);
1202
- ctx.fillRect(x + 68, y + 10, 18, 10);
1203
- ctx.fillStyle = p.chair;
1204
- ctx.fillRect(x - 8, y + 6, 8, 10);
1205
- ctx.fillRect(x - 8, y + 22, 8, 10);
1206
- ctx.fillRect(x + 100, y + 6, 8, 10);
1207
- ctx.fillRect(x + 100, y + 22, 8, 10);
1208
- }
1209
-
1210
- drawShelfWall(ctx, x, y) {
1211
- const p = this.palette;
1212
- ctx.fillStyle = p.trim;
1213
- ctx.fillRect(x, y, 34, 44);
1214
- ctx.fillStyle = p.wood;
1215
- for (let y0 = y + 4; y0 < y + 40; y0 += 12) {
1216
- ctx.fillRect(x + 2, y0, 30, 2);
1217
- }
1218
- ctx.fillStyle = p.warning;
1219
- ctx.fillRect(x + 5, y + 7, 5, 7);
1220
- ctx.fillRect(x + 12, y + 7, 4, 7);
1221
- ctx.fillRect(x + 18, y + 7, 6, 7);
1222
- ctx.fillRect(x + 8, y + 19, 6, 7);
1223
- ctx.fillRect(x + 18, y + 19, 4, 7);
1224
- ctx.fillRect(x + 12, y + 31, 5, 7);
1225
- ctx.fillRect(x + 20, y + 31, 7, 7);
1226
- }
1227
-
1228
- drawCoffeeBar(ctx, x, y) {
1229
- const p = this.palette;
1230
- ctx.fillStyle = p.deskTop;
1231
- ctx.fillRect(x, y, 44, 24);
1232
- ctx.fillStyle = p.coffee;
1233
- ctx.fillRect(x + 4, y + 4, 14, 12);
1234
- ctx.fillStyle = p.paper;
1235
- ctx.fillRect(x + 24, y + 5, 6, 8);
1236
- ctx.fillRect(x + 32, y + 7, 5, 6);
1237
- }
1238
-
1239
- drawPrinterNook(ctx, x, y) {
1240
- const p = this.palette;
1241
- ctx.fillStyle = p.deskTop;
1242
- ctx.fillRect(x, y, 42, 24);
1243
- ctx.fillStyle = p.screen;
1244
- ctx.fillRect(x + 7, y + 5, 20, 10);
1245
- ctx.fillStyle = p.paper;
1246
- ctx.fillRect(x + 12, y + 2, 10, 5);
1247
- ctx.fillRect(x + 30, y + 8, 7, 5);
1248
- }
1249
-
1250
- drawLounge(ctx, x, y) {
1251
- const p = this.palette;
1252
- ctx.fillStyle = p.rug;
1253
- ctx.fillRect(x, y, 48, 30);
1254
- ctx.fillStyle = p.chair;
1255
- ctx.fillRect(x + 4, y + 8, 14, 14);
1256
- ctx.fillRect(x + 30, y + 8, 14, 14);
1257
- ctx.fillStyle = p.wood;
1258
- ctx.fillRect(x + 20, y + 11, 8, 8);
1259
- }
1260
-
1261
- drawDeskCluster(ctx, x, y, key) {
1262
- const p = this.palette;
1263
- const structure = this.getStructure(key);
1264
- ctx.fillStyle = p.shadow;
1265
- ctx.fillRect(x - 4, y + structure.h + 2, structure.w + 8, 4);
1266
- ctx.fillStyle = p.deskTop;
1267
- ctx.fillRect(x, y, structure.w, structure.h);
1268
- ctx.fillStyle = p.desk;
1269
- ctx.fillRect(x + 4, y + 4, structure.w - 8, structure.h - 8);
1270
- ctx.fillStyle = p.screen;
1271
- ctx.fillRect(x + 8, y + 8, structure.w - 16, 10);
1272
- ctx.fillStyle = p.screenGlow;
1273
- ctx.fillRect(x + 10, y + 10, structure.w - 20, 4);
1274
- ctx.fillStyle = p.paper;
1275
- ctx.fillRect(x + 10, y + structure.h - 12, 10, 6);
1276
- ctx.fillStyle = p.chair;
1277
- ctx.fillRect(x + Math.floor(structure.w / 2) - 8, y + structure.h + 2, 16, 8);
1278
- if (key === "memory") {
1279
- ctx.fillStyle = p.warning;
1280
- ctx.fillRect(x + structure.w - 18, y + 22, 10, 6);
1281
- } else if (key === "browser") {
1282
- ctx.fillStyle = p.info;
1283
- ctx.fillRect(x + structure.w - 18, y + 22, 10, 6);
1284
- } else if (key === "social") {
1285
- ctx.fillStyle = "#ec4899";
1286
- ctx.fillRect(x + structure.w - 18, y + 22, 10, 6);
1287
- } else if (key === "cli") {
1288
- ctx.fillStyle = "#f97316";
1289
- ctx.fillRect(x + structure.w - 18, y + 22, 10, 6);
1290
- } else {
1291
- ctx.fillStyle = p.success;
1292
- ctx.fillRect(x + structure.w - 18, y + 22, 10, 6);
1293
- }
1294
- }
1295
-
1296
- drawStructures(ctx) {
1297
- for (const structure of this.structures) {
1298
- const glowSize = Math.floor(structure.glow * 7);
1299
- if (glowSize > 0) {
1300
- ctx.fillStyle = this.hexToRgba(structure.color, 0.18);
1301
- ctx.fillRect(structure.x - glowSize, structure.y - glowSize, structure.w + glowSize * 2, structure.h + glowSize * 2);
1302
- }
1303
- if (structure.glow > 0.08) {
1304
- ctx.fillStyle = this.hexToRgba(structure.color, 0.18 + structure.glow * 0.08);
1305
- ctx.fillRect(structure.x, structure.y, structure.w, 3);
1306
- }
1307
- }
1308
- }
1309
-
1310
- drawWalkways(ctx) {
1311
- const core = this.getStructure("core");
1312
- for (const structure of this.structures) {
1313
- if (structure.key === "core") continue;
1314
- const x1 = core.x + Math.floor(core.w / 2);
1315
- const y1 = core.y + core.h - 2;
1316
- const x2 = structure.x + Math.floor(structure.w / 2);
1317
- const y2 = structure.y + Math.floor(structure.h / 2);
1318
- ctx.fillStyle = this.hexToRgba(structure.color, 0.06 + structure.glow * 0.08);
1319
- for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x += 6) {
1320
- ctx.fillRect(x, y1, 2, 2);
1321
- }
1322
- for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y += 6) {
1323
- ctx.fillRect(x2, y, 2, 2);
1324
- }
1325
- }
1326
- }
1327
-
1328
- drawBots(ctx, t) {
1329
- const leadBounce = Math.sin(t * 0.08 + this.mainAgent.phase) > 0 ? 0 : 1;
1330
- this.drawBot(ctx, this.mainAgent.x, this.mainAgent.y - leadBounce, this.mainAgent.tint, true, true);
1331
- for (const helper of this.helpers) {
1332
- const bounce = Math.sin(t * 0.08 + helper.phase) > 0 ? 0 : 1;
1333
- this.drawBot(ctx, helper.x, helper.y - bounce, helper.tint, helper.lastActive + 80 > this.tick, false);
1334
- }
1335
- }
1336
-
1337
- drawBot(ctx, x, y, tint, active, isLead = false) {
1338
- const p = this.palette;
1339
- if (isLead) {
1340
- ctx.fillStyle = this.hexToRgba(p.success, 0.18);
1341
- ctx.fillRect(x - 12, y - 15, 24, 20);
1342
- }
1343
- ctx.fillStyle = p.shadow;
1344
- ctx.fillRect(x - 8, y + 8, 16, 4);
1345
- ctx.fillStyle = "#111318";
1346
- ctx.fillRect(x - 7, y - 6, 14, 12);
1347
- ctx.fillStyle = tint;
1348
- ctx.fillRect(x - 6, y - 5, 12, 10);
1349
- ctx.fillStyle = this.hexToRgba("#ffffff", 0.18);
1350
- ctx.fillRect(x - 5, y - 4, 10, 2);
1351
- ctx.fillStyle = p.paper;
1352
- ctx.fillRect(x - 3, y - 1, 2, 2);
1353
- ctx.fillRect(x + 1, y - 1, 2, 2);
1354
- ctx.fillStyle = active ? p.success : p.chair;
1355
- ctx.fillRect(x - 4, y + 5, 8, 3);
1356
- ctx.fillRect(x - 7, y + 1, 3, 2);
1357
- ctx.fillRect(x + 4, y + 1, 3, 2);
1358
- ctx.fillStyle = "#111318";
1359
- ctx.fillRect(x - 4, y + 8, 2, 3);
1360
- ctx.fillRect(x + 2, y + 8, 2, 3);
1361
- if (isLead) {
1362
- ctx.fillStyle = p.success;
1363
- ctx.fillRect(x - 2, y - 9, 4, 3);
1364
- }
1365
- }
1366
-
1367
- drawPackets(ctx) {
1368
- for (const packet of this.packets) {
1369
- ctx.fillStyle = packet.color;
1370
- ctx.fillRect(Math.round(packet.x), Math.round(packet.y), 4, 4);
1371
- ctx.fillStyle = this.palette.paper;
1372
- ctx.fillRect(Math.round(packet.x) + 1, Math.round(packet.y) + 1, 2, 2);
1373
- }
1374
- }
1375
-
1376
- drawStatusEffects(ctx, t) {
1377
- const p = this.palette;
1378
- if (this.scanFlash > 0.02) {
1379
- ctx.fillStyle = this.hexToRgba(p.info, Math.min(0.08, this.scanFlash * 0.06));
1380
- const x = 24 + ((t * 2) % 320);
1381
- ctx.fillRect(x, 20, 8, 176);
1382
- }
1383
- if (this.socialPulse > 0.02) {
1384
- ctx.fillStyle = this.hexToRgba("#ec4899", Math.min(0.1, this.socialPulse * 0.08));
1385
- ctx.fillRect(284, 122, 72, 62);
1386
- }
1387
- if (this.errorFlash > 0.02) {
1388
- ctx.fillStyle = this.hexToRgba(p.error, Math.min(0.1, this.errorFlash * 0.1));
1389
- ctx.fillRect(0, 0, 384, 216);
1390
- }
1391
- }
1392
-
1393
- drawScanlines(ctx) {
1394
- ctx.fillStyle = this.palette.isDark ? "rgba(4, 8, 14, 0.015)" : "rgba(255, 255, 255, 0.015)";
1395
- for (let y = 0; y < 216; y += 4) {
1396
- ctx.fillRect(0, y, 384, 1);
1397
- }
1398
- }
1399
-
1400
- hexToRgba(hex, alpha) {
1401
- const clean = hex.replace("#", "");
1402
- const value = parseInt(clean, 16);
1403
- const r = (value >> 16) & 255;
1404
- const g = (value >> 8) & 255;
1405
- const b = value & 255;
1406
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
1407
- }
1408
- }
1409
-
1410
- let pixelWorld = null;
1411
-
1412
- function ensureWorld() {
1413
- if (pixelWorld) return;
1414
- const canvas = document.getElementById("worldCanvas");
1415
- if (!canvas) return;
1416
- pixelWorld = new PixelWorld(canvas);
1417
- window.pixelWorld = pixelWorld;
1418
- }
1419
-
1420
- function resetWorldForNewRun() {
1421
- ensureWorld();
1422
- if (pixelWorld) pixelWorld.resetForNewRun();
1423
- }
1424
-
1425
- ensureWorld();
1426
- navigateTo(getPageFromLocation(), { push: false });
1427
-
1428
- // ── Socket Events ──
1429
-
1430
- socket.on("run:start", (data) => {
1431
- if (
1432
- data.triggerSource === "scheduler" ||
1433
- data.triggerSource === "heartbeat"
1434
- ) {
1435
- backgroundRunIds.add(data.runId);
1436
- return;
1437
- }
1438
- ensureWorld();
1439
- if (pixelWorld) pixelWorld.onRunStart(data);
1440
- });
1441
-
1442
- socket.on("run:thinking", (data) => {
1443
- if (backgroundRunIds.has(data.runId)) return;
1444
- const textEl = $("#thinkingText");
1445
- if (textEl) textEl.textContent = `Thinking… (step ${data.iteration})`;
1446
- ensureWorld();
1447
- if (pixelWorld) pixelWorld.onThinking(data);
1448
- });
1449
-
1450
- socket.on("run:tool_start", (data) => {
1451
- if (backgroundRunIds.has(data.runId)) return;
1452
- ensureWorld();
1453
- if (pixelWorld) pixelWorld.onToolStart(data);
1454
- const textEl = $("#thinkingText");
1455
- if (textEl) textEl.textContent = `${data.toolName}…`;
1456
- });
1457
-
1458
- socket.on("run:tool_end", (data) => {
1459
- if (backgroundRunIds.has(data.runId)) return;
1460
- ensureWorld();
1461
- if (pixelWorld) pixelWorld.onToolEnd(data);
1462
- });
1463
-
1464
- socket.on("run:stream", (data) => {
1465
- if (
1466
- backgroundRunIds.has(data.runId) ||
1467
- data.triggerSource === "scheduler" ||
1468
- data.triggerSource === "heartbeat" ||
1469
- data.triggerSource === "messaging"
1470
- )
1471
- return;
1472
-
1473
- const text = data.content || data;
1474
- const chunks = text.split(/\n\n+/).filter(c => c.trim().length > 0 || c === text);
1475
-
1476
- let streamContainer = $("#streamContainer");
1477
- if (!streamContainer) {
1478
- const thinking = $("#thinking");
1479
- if (thinking) thinking.remove();
1480
-
1481
- streamContainer = document.createElement("div");
1482
- streamContainer.id = "streamContainer";
1483
- streamContainer.className = "chat-stream-group";
1484
- chatMessages.appendChild(streamContainer);
1485
- }
1486
-
1487
- streamContainer.innerHTML = "";
1488
- for (let i = 0; i < chunks.length; i++) {
1489
- const div = document.createElement("div");
1490
- div.className = "chat-message assistant";
1491
- if (i > 0) div.style.marginTop = "8px";
1492
- div.innerHTML = `<div class="chat-avatar">${i === 0 ? 'N' : ''}</div><div class="chat-bubble md-content">${renderMarkdown(chunks[i])}</div>`;
1493
- streamContainer.appendChild(div);
1494
- }
1495
- requestAnimationFrame(renderMermaids);
1496
- chatMessages.scrollTop = chatMessages.scrollHeight;
1497
- });
1498
-
1499
- socket.on("run:complete", (data) => {
1500
- const isBackground =
1501
- backgroundRunIds.has(data.runId) ||
1502
- data.triggerSource === "scheduler" ||
1503
- data.triggerSource === "heartbeat";
1504
- if (isBackground) backgroundRunIds.delete(data.runId);
1505
-
1506
- if (!isBackground) {
1507
- const thinking = $("#thinking");
1508
- if (thinking) thinking.remove();
1509
-
1510
- const streamContainer = $("#streamContainer");
1511
- if (streamContainer) {
1512
- streamContainer.id = "";
1513
- if (data.content) {
1514
- const chunks = data.content.split(/\n\n+/).filter(c => c.trim().length > 0 || c === data.content);
1515
- streamContainer.innerHTML = "";
1516
- for (let i = 0; i < chunks.length; i++) {
1517
- const div = document.createElement("div");
1518
- div.className = "chat-message assistant";
1519
- if (i > 0) div.style.marginTop = "8px";
1520
- div.innerHTML = `<div class="chat-avatar">${i === 0 ? 'N' : ''}</div><div class="chat-bubble md-content">${renderMarkdown(chunks[i])}</div>`;
1521
- streamContainer.appendChild(div);
1522
- }
1523
- requestAnimationFrame(renderMermaids);
1524
- }
1525
- } else if (data.content && data.triggerSource !== "messaging") {
1526
- appendMessage("assistant", data.content);
1527
- }
1528
-
1529
- ensureWorld();
1530
- if (pixelWorld) pixelWorld.onRunComplete(data);
1531
-
1532
- isStreaming = false;
1533
- sendBtn.disabled = false;
1534
- }
1535
- });
1536
-
1537
- socket.on("chat:cleared", () => {
1538
- chatMessages.innerHTML = "";
1539
- if (chatEmpty) chatEmpty.classList.remove("hidden");
1540
- });
1541
-
1542
- socket.on("run:error", (data) => {
1543
- const thinking = $("#thinking");
1544
- if (thinking) thinking.remove();
1545
- const errMsg = data.error || "Unknown error";
1546
- appendMessage("assistant", `❌ ${errMsg}`);
1547
- ensureWorld();
1548
- if (pixelWorld) pixelWorld.onRunError(data);
1549
- isStreaming = false;
1550
- sendBtn.disabled = false;
1551
- toast(errMsg, "error");
1552
- });
1553
-
1554
- // AI sends a status update during a long task
1555
- socket.on("run:interim", (data) => {
1556
- const textEl = $("#thinkingText");
1557
- if (textEl) textEl.textContent = data.message;
1558
- appendInterimMessage(data.message);
1559
- ensureWorld();
1560
- if (pixelWorld) pixelWorld.pushEvent("note", data.message);
1561
- });
1562
-
1563
- // Incoming social message → show in chat + world visualization
1564
- socket.on("messaging:message", (data) => {
1565
- appendSocialMessage(data.platform, "user", data.content, data.senderName);
1566
- ensureWorld();
1567
- if (pixelWorld) pixelWorld.onMessage(data);
1568
- });
1569
-
1570
- socket.on("skill:draft_created", (data) => {
1571
- toast(`Draft skill created: ${data.name}`, "success");
1572
- if (!$("#skillList")?.classList.contains("hidden")) {
1573
- loadSkillsPage();
1574
- }
1575
- });
1576
-
1577
- // ── Logs Tab ──
1578
-
1579
- const logsContainer = $("#logsContainer");
1580
- let logsRequested = false;
1581
-
1582
- function loadLogsPage() {
1583
- if (!logsRequested) {
1584
- socket.emit("client:request_logs");
1585
- logsRequested = true;
1586
- }
1587
- }
1588
-
1589
- function appendLogEntry(log) {
1590
- if (!logsContainer) return;
1591
-
1592
- const isAtBottom =
1593
- logsContainer.scrollHeight - logsContainer.scrollTop <=
1594
- logsContainer.clientHeight + 50;
1595
-
1596
- const el = document.createElement("div");
1597
- el.style.marginBottom = "4px";
1598
- el.style.borderBottom = "1px solid #1e293b";
1599
- el.style.paddingBottom = "4px";
1600
- el.style.wordBreak = "break-word";
1601
- el.style.whiteSpace = "pre-wrap";
1602
-
1603
- let color = "#e2e8f0"; // info
1604
- if (log.type === "error")
1605
- color = "#f87171"; // red
1606
- else if (log.type === "warn")
1607
- color = "#fbbf24"; // yellow
1608
- else if (log.type === "log") color = "#94a3b8"; // gray
1609
-
1610
- const timeStr = new Date(log.timestamp).toLocaleTimeString([], {
1611
- hour12: false,
1612
- });
1613
- el.innerHTML = `<span style="color:#64748b;margin-right:8px;">[${timeStr}]</span><span style="color:${color};">${escapeHtml(log.message)}</span>`;
1614
-
1615
- logsContainer.appendChild(el);
1616
-
1617
- if (isAtBottom) {
1618
- logsContainer.scrollTop = logsContainer.scrollHeight;
1619
- }
1620
- }
1621
-
1622
- socket.on("server:log", (log) => {
1623
- appendLogEntry(log);
1624
- });
1625
-
1626
- socket.on("server:log_history", (history) => {
1627
- if (logsContainer) {
1628
- logsContainer.innerHTML = "";
1629
- history.forEach(appendLogEntry);
1630
- logsContainer.scrollTop = logsContainer.scrollHeight;
1631
- }
1632
- });
1633
-
1634
- const clearLogsBtn = $("#clearLogsBtn");
1635
- if (clearLogsBtn) {
1636
- clearLogsBtn.addEventListener("click", () => {
1637
- if (logsContainer) logsContainer.innerHTML = "";
1638
- });
1639
- }
1640
-
1641
- const copyLogsBtn = $("#copyLogsBtn");
1642
- if (copyLogsBtn) {
1643
- copyLogsBtn.addEventListener("click", async () => {
1644
- try {
1645
- let debugText = "=== SYSTEM DEBUG INFO ===\\n\\n";
1646
-
1647
- debugText += "--- CHAT HISTORY ---\\n";
1648
- const chats = document.querySelectorAll(".chat-message");
1649
- chats.forEach((c) => {
1650
- const sender = c.classList.contains("user") ? "USER" : "AI";
1651
- const content =
1652
- c.querySelector(".md-content")?.innerText || c.innerText;
1653
- debugText += `[${sender}]\\n${content.trim()}\\n\\n`;
1654
- });
1655
-
1656
- debugText += "--- WORLD EVENT FEED ---\\n";
1657
- const entries = document.querySelectorAll(".world-event-entry");
1658
- entries.forEach((entry) => {
1659
- const title = entry.querySelector(".world-event-tag")?.innerText || "EVENT";
1660
- const details =
1661
- entry.querySelector(".world-event-text")?.innerText || "No details";
1662
- debugText += `${title}\\n${details}\\n\\n`;
1663
- });
1664
-
1665
- debugText += "--- CONSOLE LOGS ---\\n";
1666
- if (logsContainer) {
1667
- debugText += logsContainer.innerText || "No logs available.";
1668
- }
1669
-
1670
- await navigator.clipboard.writeText(debugText);
1671
-
1672
- const originalText = copyLogsBtn.textContent;
1673
- copyLogsBtn.textContent = "Copied!";
1674
- setTimeout(() => {
1675
- copyLogsBtn.textContent = originalText;
1676
- }, 2000);
1677
-
1678
- toast("Debug info copied to clipboard", "success");
1679
- } catch (err) {
1680
- toast("Failed to copy debug info: " + err.message, "error");
1681
- }
1682
- });
1683
- }
1684
-
1685
- // ── Settings ──
1686
-
1687
- let updateStatusPollTimer = null;
1688
- let updateFinishNotifiedAt = null;
1689
- let backendVersionLabel = null;
1690
-
1691
- function clearUpdatePoll() {
1692
- if (updateStatusPollTimer) {
1693
- clearInterval(updateStatusPollTimer);
1694
- updateStatusPollTimer = null;
1695
- }
1696
- }
1697
-
1698
- function setUpdateBadgeState(state) {
1699
- const badge = $("#updateStateBadge");
1700
- if (!badge) return;
1701
-
1702
- badge.classList.remove("badge-neutral", "badge-info", "badge-success", "badge-error", "badge-warning");
1703
- if (state === "running") {
1704
- badge.classList.add("badge-info");
1705
- badge.textContent = "Running";
1706
- } else if (state === "completed") {
1707
- badge.classList.add("badge-success");
1708
- badge.textContent = "Completed";
1709
- } else if (state === "failed") {
1710
- badge.classList.add("badge-error");
1711
- badge.textContent = "Failed";
1712
- } else {
1713
- badge.classList.add("badge-neutral");
1714
- badge.textContent = "Idle";
1715
- }
1716
- }
1717
-
1718
- function renderUpdateStatus(status) {
1719
- const state = status?.state || "idle";
1720
- const progress = Math.max(0, Math.min(100, Number(status?.progress || 0)));
1721
-
1722
- setUpdateBadgeState(state);
1723
- $("#updateProgressBar").style.width = `${progress}%`;
1724
- $("#updatePercentLabel").textContent = `${progress}%`;
1725
- $("#updatePhaseLabel").textContent = status?.message || "No update running";
1726
-
1727
- const before = status?.versionBefore || "—";
1728
- const after = status?.versionAfter || "—";
1729
- const updateVersionLabel = `${before}${after !== "—" ? ` -> ${after}` : ""}`;
1730
- const backendLabel = backendVersionLabel ? ` | Backend: ${backendVersionLabel}` : "";
1731
- $("#updateVersionMeta").textContent = `Update Version: ${updateVersionLabel}${backendLabel}`;
1732
-
1733
- const changelog = $("#updateChangelog");
1734
- changelog.innerHTML = "";
1735
- const entries = Array.isArray(status?.changelog) ? status.changelog : [];
1736
- if (!entries.length) {
1737
- const li = document.createElement("li");
1738
- li.className = "settings-update-empty";
1739
- li.textContent = "No commit changes captured";
1740
- changelog.appendChild(li);
1741
- } else {
1742
- for (const line of entries) {
1743
- const li = document.createElement("li");
1744
- li.textContent = line;
1745
- changelog.appendChild(li);
1746
- }
1747
- }
1748
-
1749
- const logs = Array.isArray(status?.logs) ? status.logs : [];
1750
- const logsText = logs.length ? logs.slice(-120).join("\n") : "Waiting for update job output…";
1751
- const logsEl = $("#updateLogs");
1752
- logsEl.textContent = logsText;
1753
- logsEl.scrollTop = logsEl.scrollHeight;
1754
-
1755
- const btn = $("#updateAppBtn");
1756
- if (state === "running") {
1757
- btn.disabled = true;
1758
- btn.textContent = "Updating…";
1759
- } else {
1760
- btn.disabled = false;
1761
- btn.textContent = "Update App";
1762
- }
1763
-
1764
- if ((state === "completed" || state === "failed") && status?.completedAt && updateFinishNotifiedAt !== status.completedAt) {
1765
- updateFinishNotifiedAt = status.completedAt;
1766
- toast(state === "completed" ? "Update completed." : "Update failed. See logs in Settings.", state === "completed" ? "success" : "error");
1767
- }
1768
- }
1769
-
1770
- async function refreshUpdateStatus() {
1771
- try {
1772
- const status = await api("/settings/update/status");
1773
- renderUpdateStatus(status);
1774
-
1775
- if (status?.state !== "running") {
1776
- clearUpdatePoll();
1777
- }
1778
- } catch (err) {
1779
- // During restart window this can fail briefly; keep trying.
1780
- // If endpoint is unavailable (older backend), stop polling to avoid console spam.
1781
- if (err?.status === 404) {
1782
- clearUpdatePoll();
1783
- $("#updatePhaseLabel").textContent = "Update status unavailable on this server version.";
1784
- $("#updatePercentLabel").textContent = "—";
1785
- setUpdateBadgeState("idle");
1786
- const btn = $("#updateAppBtn");
1787
- if (btn) btn.disabled = false;
1788
- return;
1789
- }
1790
- $("#updatePhaseLabel").textContent = "Reconnecting to server…";
1791
- setUpdateBadgeState("running");
1792
- }
1793
- }
1794
-
1795
- function ensureUpdatePolling(force = false) {
1796
- if (force) clearUpdatePoll();
1797
- if (!updateStatusPollTimer) {
1798
- updateStatusPollTimer = setInterval(refreshUpdateStatus, 1800);
1799
- }
1800
- }
1801
-
1802
- function formatInt(n) {
1803
- return Number(n || 0).toLocaleString();
1804
- }
1805
-
1806
- function renderTokenUsageSummary(summary) {
1807
- const el = $("#tokenUsageSummary");
1808
- if (!el) return;
1809
- const totals = summary?.totals || {};
1810
- el.innerHTML = `
1811
- <div>Total: <strong>${formatInt(totals.totalTokens)}</strong> tokens across <strong>${formatInt(totals.totalRuns)}</strong> runs</div>
1812
- <div>Last 7 days: <strong>${formatInt(totals.last7DaysTokens)}</strong> tokens in <strong>${formatInt(totals.last7DaysRuns)}</strong> runs</div>
1813
- <div>Avg/run: <strong>${formatInt(totals.avgTokensPerRun)}</strong> tokens</div>
1814
- `;
1815
- }
1816
-
1817
- $("#settingsBtn").addEventListener("click", async () => {
1818
- try {
1819
- const [meta, settings] = await Promise.all([
1820
- api("/settings/meta/models"),
1821
- api("/settings")
1822
- ]);
1823
-
1824
- try {
1825
- const backendVersion = await api("/version");
1826
- backendVersionLabel = backendVersion?.version || "unknown";
1827
- const vEl = $("#settingsAppVersion");
1828
- if (vEl && backendVersionLabel !== "unknown") {
1829
- vEl.textContent = `v${backendVersionLabel}`;
1830
- }
1831
- } catch {
1832
- backendVersionLabel = "unavailable";
1833
- }
1834
-
1835
- try {
1836
- const tokenUsage = await api("/settings/token-usage/summary");
1837
- renderTokenUsageSummary(tokenUsage);
1838
- } catch (err) {
1839
- const tokenBox = $("#tokenUsageSummary");
1840
- if (tokenBox) tokenBox.textContent = "Token usage unavailable on this server version.";
1841
- }
1842
-
1843
- $("#settingHeartbeat").checked =
1844
- settings.heartbeat_enabled === true ||
1845
- settings.heartbeat_enabled === "true";
1846
- $("#settingHeadlessBrowser").checked =
1847
- settings.headless_browser !== false &&
1848
- settings.headless_browser !== "false";
1849
- $("#settingAutoSkillLearning").checked =
1850
- settings.auto_skill_learning !== false &&
1851
- settings.auto_skill_learning !== "false";
1852
- $("#settingSmarterModelSelector").checked =
1853
- settings.smarter_model_selector !== false &&
1854
- settings.smarter_model_selector !== "false";
1855
-
1856
- const enabledModels = Array.isArray(settings.enabled_models) ? settings.enabled_models : (meta.models || []).map(m => m.id);
1857
-
1858
- const chatModelSelect = $("#settingDefaultChatModel");
1859
- const subagentModelSelect = $("#settingDefaultSubagentModel");
1860
-
1861
- if (chatModelSelect && subagentModelSelect && meta.models) {
1862
- chatModelSelect.innerHTML = '<option value="auto">Smart Selector (Auto)</option>';
1863
- subagentModelSelect.innerHTML = '<option value="auto">Smart Selector (Auto)</option>';
1864
- const fallbackModelSelect = $("#settingFallbackModelId");
1865
- if (fallbackModelSelect) fallbackModelSelect.innerHTML = "";
1866
-
1867
- for (const modelDef of meta.models) {
1868
- const chatOption = document.createElement("option");
1869
- chatOption.value = modelDef.id;
1870
- chatOption.textContent = modelDef.label;
1871
- chatModelSelect.appendChild(chatOption);
1872
-
1873
- const subagentOption = document.createElement("option");
1874
- subagentOption.value = modelDef.id;
1875
- subagentOption.textContent = modelDef.label;
1876
- subagentModelSelect.appendChild(subagentOption);
1877
-
1878
- if (fallbackModelSelect) {
1879
- const fallbackOption = document.createElement("option");
1880
- fallbackOption.value = modelDef.id;
1881
- fallbackOption.textContent = modelDef.label;
1882
- fallbackModelSelect.appendChild(fallbackOption);
1883
- }
1884
- }
1885
-
1886
- chatModelSelect.value = settings.default_chat_model || "auto";
1887
- subagentModelSelect.value = settings.default_subagent_model || "auto";
1888
- if ($("#settingFallbackModelId")) {
1889
- $("#settingFallbackModelId").value = settings.fallback_model_id || "gpt-5-nano";
1890
- }
1891
-
1892
- const indicator = $("#modelIndicator");
1893
- if (indicator) {
1894
- if (settings.default_chat_model && settings.default_chat_model !== "auto") {
1895
- const selectedModel = meta.models.find(m => m.id === settings.default_chat_model);
1896
- indicator.textContent = selectedModel ? selectedModel.label : "Smart Selector Active";
1897
- } else {
1898
- indicator.textContent = "Smart Selector Active";
1899
- }
1900
- }
1901
- }
1902
-
1903
- const container = $("#modelCheckboxesContainer");
1904
- if (container) {
1905
- container.innerHTML = "";
1906
- if (meta.models) {
1907
- for (const modelDef of meta.models) {
1908
- const label = document.createElement("label");
1909
- label.className = "flex items-center gap-2";
1910
- label.style.cursor = "pointer";
1911
-
1912
- const checkbox = document.createElement("input");
1913
- checkbox.type = "checkbox";
1914
- checkbox.className = "dynamic-model-checkbox";
1915
- checkbox.dataset.modelId = modelDef.id;
1916
- checkbox.autocomplete = "off";
1917
- checkbox.setAttribute("data-bwignore", "true");
1918
- checkbox.checked = enabledModels.includes(modelDef.id);
1919
-
1920
- const span = document.createElement("span");
1921
- span.textContent = modelDef.label;
1922
-
1923
- label.appendChild(checkbox);
1924
- label.appendChild(span);
1925
- container.appendChild(label);
1926
- }
1927
- }
1928
- }
1929
- } catch (err) {
1930
- console.error("Failed to load settings:", err);
1931
- $("#settingHeadlessBrowser").checked = true; // default headless
1932
- const tokenBox = $("#tokenUsageSummary");
1933
- if (tokenBox) tokenBox.textContent = "Token usage unavailable.";
1934
- backendVersionLabel = "unavailable";
1935
- }
1936
- await refreshUpdateStatus();
1937
- ensureUpdatePolling(true);
1938
- $("#settingsModal").classList.remove("hidden");
1939
- });
1940
-
1941
- $("#closeSettings").addEventListener("click", () => {
1942
- clearUpdatePoll();
1943
- $("#settingsModal").classList.add("hidden");
1944
- });
1945
- $("#cancelSettings").addEventListener("click", () => {
1946
- clearUpdatePoll();
1947
- $("#settingsModal").classList.add("hidden");
1948
- });
1949
-
1950
- $("#saveSettings").addEventListener("click", async () => {
1951
- try {
1952
- const enabledModels = Array.from(document.querySelectorAll("#modelCheckboxesContainer .dynamic-model-checkbox"))
1953
- .filter(cb => cb.checked)
1954
- .map(cb => cb.dataset.modelId);
1955
-
1956
- const defaultChatModel = $("#settingDefaultChatModel").value;
1957
- const defaultSubagentModel = $("#settingDefaultSubagentModel").value;
1958
-
1959
- await api("/settings", {
1960
- method: "PUT",
1961
- body: {
1962
- heartbeat_enabled: $("#settingHeartbeat").checked,
1963
- headless_browser: $("#settingHeadlessBrowser").checked,
1964
- auto_skill_learning: $("#settingAutoSkillLearning").checked,
1965
- smarter_model_selector: $("#settingSmarterModelSelector").checked,
1966
- enabled_models: enabledModels,
1967
- default_chat_model: defaultChatModel,
1968
- default_subagent_model: defaultSubagentModel,
1969
- fallback_model_id: $("#settingFallbackModelId") ? $("#settingFallbackModelId").value : 'gpt-5-nano'
1970
- },
1971
- });
1972
-
1973
- const indicator = $("#modelIndicator");
1974
- if (indicator) {
1975
- if (defaultChatModel !== "auto") {
1976
- const selectedOption = $("#settingDefaultChatModel").options[$("#settingDefaultChatModel").selectedIndex];
1977
- indicator.textContent = selectedOption ? selectedOption.text : "Smart Selector Active";
1978
- } else {
1979
- indicator.textContent = "Smart Selector Active";
1980
- }
1981
- }
1982
-
1983
- $("#settingsModal").classList.add("hidden");
1984
- toast("Settings saved", "success");
1985
- } catch (err) {
1986
- toast("Failed to save settings", "error");
1987
- }
1988
- });
1989
-
1990
- $("#updateAppBtn").addEventListener("click", async () => {
1991
- if (!confirm("Are you sure you want to run the update script? This will trigger neoagent update and restart the server.")) return;
1992
- try {
1993
- await api("/settings/update", { method: "POST" });
1994
- updateFinishNotifiedAt = null;
1995
- toast("Update started. Live progress is shown below.", "success");
1996
- await refreshUpdateStatus();
1997
- ensureUpdatePolling(true);
1998
- } catch (err) {
1999
- toast("Failed to trigger update: " + err.message, "error");
2000
- await refreshUpdateStatus();
2001
- }
2002
- });
2003
-
2004
- // ── Logout ──
2005
-
2006
- $("#logoutBtn").addEventListener("click", async () => {
2007
- try {
2008
- await fetch("/api/auth/logout", { method: "POST" });
2009
- window.location.href = "/login";
2010
- } catch (err) {
2011
- window.location.href = "/login";
2012
- }
2013
- });
2014
-
2015
- // ── Memory Page ──
2016
-
2017
- // Category badge colours
2018
- const CAT_COLORS = {
2019
- user_fact: {
2020
- bg: "#3b82f620",
2021
- border: "#3b82f6",
2022
- text: "#3b82f6",
2023
- label: "User Fact",
2024
- },
2025
- preference: {
2026
- bg: "#8b5cf620",
2027
- border: "#8b5cf6",
2028
- text: "#8b5cf6",
2029
- label: "Preference",
2030
- },
2031
- personality: {
2032
- bg: "#ec489920",
2033
- border: "#ec4899",
2034
- text: "#ec4899",
2035
- label: "Personality",
2036
- },
2037
- episodic: {
2038
- bg: "#22c55e20",
2039
- border: "#22c55e",
2040
- text: "#22c55e",
2041
- label: "Episodic",
2042
- },
2043
- };
2044
-
2045
- let _memActiveCategory = "";
2046
- let _memCurrentPage = 0;
2047
-
2048
- async function loadMemoryPage() {
2049
- try {
2050
- const data = await api("/memory");
2051
-
2052
- // Soul
2053
- if ($("#soulEditor")) $("#soulEditor").value = data.soul || "";
2054
-
2055
- // Daily logs
2056
- const dailyContainer = $("#dailyLogs");
2057
- if (dailyContainer) {
2058
- dailyContainer.innerHTML = "";
2059
- for (const log of data.dailyLogs || []) {
2060
- const card = document.createElement("div");
2061
- card.className = "item-card";
2062
- card.innerHTML = `<div class="item-card-header"><div class="item-card-title">${escapeHtml(log.date)}</div></div><pre class="code-block">${escapeHtml(log.content || "Empty")}</pre>`;
2063
- dailyContainer.appendChild(card);
2064
- }
2065
- }
2066
-
2067
- // Core memory
2068
- _renderCoreMemory(data.coreMemory || {});
2069
-
2070
- // API keys
2071
- const keyContainer = $("#apiKeyList");
2072
- if (keyContainer) {
2073
- keyContainer.innerHTML = "";
2074
- const keys = await api("/memory/api-keys");
2075
- for (const [name, masked] of Object.entries(keys)) {
2076
- const card = document.createElement("div");
2077
- card.className = "item-card flex justify-between items-center";
2078
- card.innerHTML = `<div><div class="item-card-title">${escapeHtml(name)}</div><div class="item-card-meta font-mono">${escapeHtml(masked)}</div></div>
2079
- <button class="btn btn-sm btn-danger" data-action="deleteApiKey" data-name="${escapeHtml(name)}">&times;</button>`;
2080
- keyContainer.appendChild(card);
2081
- }
2082
- }
2083
-
2084
- // Memories list
2085
- await _loadMemoriesTab(_memActiveCategory);
2086
- await loadSessionRecall();
2087
- } catch (err) {
2088
- toast("Failed to load memory", "error");
2089
- }
2090
- }
2091
-
2092
- async function loadSessionRecall(query = "") {
2093
- const container = $("#sessionRecallList");
2094
- if (!container) return;
2095
- container.innerHTML =
2096
- '<div class="empty-state"><p>Loading session recall…</p></div>';
2097
- try {
2098
- const results = query
2099
- ? await api("/memory/conversations/search", {
2100
- method: "POST",
2101
- body: { query, limit: 8 },
2102
- })
2103
- : await api("/memory/conversations?limit=12");
2104
-
2105
- if (!results.length) {
2106
- container.innerHTML =
2107
- '<div class="empty-state"><p>No matching sessions yet.</p></div>';
2108
- return;
2109
- }
2110
-
2111
- container.innerHTML = "";
2112
- for (const item of results) {
2113
- const card = document.createElement("div");
2114
- card.className = "item-card";
2115
-
2116
- if (item.matches) {
2117
- const matches = item.matches
2118
- .map(
2119
- (match) => `<div style="margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:10px;">
2120
- <div style="font-size:0.72rem;text-transform:uppercase;color:var(--text-muted);margin-bottom:4px;">${escapeHtml(match.role || "message")}</div>
2121
- <div style="font-size:0.9rem;line-height:1.45;">${escapeHtml(match.excerpt || "")}</div>
2122
- </div>`
2123
- )
2124
- .join("");
2125
-
2126
- card.innerHTML = `
2127
- <div class="item-card-header">
2128
- <div>
2129
- <div class="item-card-title">${escapeHtml(item.title || "Session")}</div>
2130
- <div class="item-card-meta">${escapeHtml(item.source || "session")} · ${escapeHtml(item.createdAt || "")}</div>
2131
- </div>
2132
- <span class="badge badge-neutral">${item.matchCount || item.matches.length} match${(item.matchCount || item.matches.length) === 1 ? "" : "es"}</span>
2133
- </div>
2134
- ${matches}
2135
- `;
2136
- } else {
2137
- card.innerHTML = `
2138
- <div class="item-card-header">
2139
- <div>
2140
- <div class="item-card-title">${escapeHtml(item.title || "Session")}</div>
2141
- <div class="item-card-meta">${escapeHtml(item.status || "completed")} · ${escapeHtml(item.completedAt || item.createdAt || "")}</div>
2142
- </div>
2143
- </div>
2144
- <div style="font-size:0.9rem;line-height:1.45;color:var(--text);">${escapeHtml(item.excerpt || "No excerpt available.")}</div>
2145
- `;
2146
- }
2147
-
2148
- container.appendChild(card);
2149
- }
2150
- } catch {
2151
- container.innerHTML =
2152
- '<div class="empty-state"><p>Session recall failed to load.</p></div>';
2153
- }
2154
- }
2155
-
2156
- async function _loadMemoriesTab(category = "") {
2157
- const container = $("#memoryList");
2158
- if (!container) return;
2159
- container.innerHTML =
2160
- '<div class="empty-state" style="grid-column:1/-1"><p>Loading…</p></div>';
2161
- try {
2162
- const params = new URLSearchParams({ limit: 60, offset: 0 });
2163
- if (category) params.set("category", category);
2164
- const memories = await api(`/memory/memories?${params}`);
2165
- _renderMemories(memories, container);
2166
- } catch {
2167
- container.innerHTML =
2168
- '<div class="empty-state" style="grid-column:1/-1"><p>Failed to load memories</p></div>';
2169
- }
2170
- }
2171
-
2172
- function _renderMemories(memories, container) {
2173
- container.innerHTML = "";
2174
- if (!memories.length) {
2175
- container.innerHTML =
2176
- '<div class="empty-state" style="grid-column:1/-1"><p>No memories yet. The agent will save things automatically, or you can add one manually.</p></div>';
2177
- return;
2178
- }
2179
- for (const mem of memories) {
2180
- const cat = CAT_COLORS[mem.category] || CAT_COLORS.episodic;
2181
- const dots =
2182
- "●".repeat(Math.round(mem.importance / 2)) +
2183
- "○".repeat(5 - Math.round(mem.importance / 2));
2184
- const date = new Date(mem.updated_at || mem.created_at);
2185
- const dateStr = date.toLocaleDateString("en-US", {
2186
- month: "short",
2187
- day: "numeric",
2188
- year: "2-digit",
2189
- });
2190
-
2191
- const card = document.createElement("div");
2192
- card.className = "card";
2193
- card.style.cssText = `margin:0;cursor:default;border-left:3px solid ${cat.border};`;
2194
- card.innerHTML = `
2195
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px;">
2196
- <span style="background:${cat.bg};color:${cat.text};border:1px solid ${cat.border};border-radius:999px;padding:2px 10px;font-size:0.72rem;font-weight:600;flex-shrink:0;">${cat.label}</span>
2197
- <div style="display:flex;align-items:center;gap:6px;flex-shrink:0;">
2198
- <span style="font-size:0.75rem;color:var(--text-muted);">${dateStr}</span>
2199
- <button class="btn btn-sm btn-danger" data-action="deleteMemory" data-id="${escapeHtml(mem.id)}" style="padding:2px 7px;font-size:0.75rem;">&times;</button>
2200
- </div>
2201
- </div>
2202
- <div style="font-size:0.9rem;line-height:1.5;color:var(--text);">${escapeHtml(mem.content)}</div>
2203
- <div style="margin-top:8px;font-size:0.75rem;color:var(--text-muted);letter-spacing:0.03em;">${dots} <span style="margin-left:4px;">importance ${mem.importance}</span>${mem.access_count > 0 ? ` · recalled ${mem.access_count}×` : ""}</div>`;
2204
- container.appendChild(card);
2205
- }
2206
- }
2207
-
2208
- function _renderCoreMemory(core) {
2209
- const container = $("#coreMemoryList");
2210
- if (!container) return;
2211
- container.innerHTML = "";
2212
- if (!Object.keys(core).length) {
2213
- const empty = document.createElement("p");
2214
- empty.className = "text-muted";
2215
- empty.style.cssText = "font-size:0.85rem;margin-bottom:8px;";
2216
- empty.textContent = "No core memory entries yet.";
2217
- container.appendChild(empty);
2218
- return;
2219
- }
2220
- for (const [key, val] of Object.entries(core)) {
2221
- const row = document.createElement("div");
2222
- row.className = "item-card";
2223
- row.style.marginBottom = "8px";
2224
- const display = typeof val === "object" ? JSON.stringify(val) : String(val);
2225
- row.innerHTML = `
2226
- <div class="item-card-header">
2227
- <div>
2228
- <div class="item-card-title" style="font-size:0.85rem;font-family:monospace;">${escapeHtml(key)}</div>
2229
- <div class="item-card-meta" style="margin-top:3px;">${escapeHtml(display.slice(0, 200))}</div>
2230
- </div>
2231
- <div class="item-card-actions">
2232
- <button class="btn btn-sm btn-secondary" data-action="editCore" data-key="${escapeHtml(key)}" data-val="${escapeHtml(display)}">Edit</button>
2233
- <button class="btn btn-sm btn-danger" data-action="deleteCore" data-key="${escapeHtml(key)}">&times;</button>
2234
- </div>
2235
- </div>`;
2236
- container.appendChild(row);
2237
- }
2238
- }
2239
-
2240
- // Tab switching for memory page
2241
- $$("[data-mem-tab]").forEach((tab) => {
2242
- tab.addEventListener("click", () => {
2243
- $$("[data-mem-tab]").forEach((t) => t.classList.remove("active"));
2244
- $$(".mem-panel").forEach((p) => p.classList.remove("active"));
2245
- tab.classList.add("active");
2246
- $(`#mem-${tab.dataset.memTab}`)?.classList.add("active");
2247
- });
2248
- });
2249
-
2250
- // Category filter
2251
- $("#memoryCategoryFilter")?.addEventListener("click", async (e) => {
2252
- const btn = e.target.closest("[data-cat]");
2253
- if (!btn) return;
2254
- _memActiveCategory = btn.dataset.cat;
2255
- $$("#memoryCategoryFilter [data-cat]").forEach((b) => {
2256
- b.className =
2257
- b.dataset.cat === _memActiveCategory
2258
- ? "btn btn-sm btn-primary"
2259
- : "btn btn-sm btn-secondary";
2260
- });
2261
- await _loadMemoriesTab(_memActiveCategory);
2262
- });
2263
-
2264
- // Semantic search
2265
- $("#memorySearchBtn")?.addEventListener("click", async () => {
2266
- const q = $("#memorySearchInput")?.value?.trim();
2267
- if (!q) {
2268
- await _loadMemoriesTab(_memActiveCategory);
2269
- return;
2270
- }
2271
- const container = $("#memoryList");
2272
- container.innerHTML =
2273
- '<div class="empty-state" style="grid-column:1/-1"><p>Searching…</p></div>';
2274
- try {
2275
- const results = await api("/memory/memories/recall", {
2276
- method: "POST",
2277
- body: { query: q, limit: 20 },
2278
- });
2279
- _renderMemories(results, container);
2280
- } catch {
2281
- container.innerHTML =
2282
- '<div class="empty-state" style="grid-column:1/-1"><p>Search failed</p></div>';
2283
- }
2284
- });
2285
-
2286
- $("#memorySearchInput")?.addEventListener("keydown", (e) => {
2287
- if (e.key === "Enter") $("#memorySearchBtn")?.click();
2288
- });
2289
-
2290
- $("#sessionSearchBtn")?.addEventListener("click", async () => {
2291
- const query = $("#sessionSearchInput")?.value?.trim() || "";
2292
- await loadSessionRecall(query);
2293
- });
2294
-
2295
- $("#sessionSearchInput")?.addEventListener("keydown", (e) => {
2296
- if (e.key === "Enter") $("#sessionSearchBtn")?.click();
2297
- });
2298
-
2299
- // Soul save
2300
- $("#saveSoulBtn")?.addEventListener("click", async () => {
2301
- try {
2302
- await api("/memory/soul", {
2303
- method: "PUT",
2304
- body: { content: $("#soulEditor").value },
2305
- });
2306
- toast("Soul saved", "success");
2307
- } catch {
2308
- toast("Failed to save", "error");
2309
- }
2310
- });
2311
-
2312
- // Add Memory Modal
2313
- $("#addMemoryBtn")?.addEventListener("click", () => {
2314
- $("#addMemoryModal")?.classList.remove("hidden");
2315
- });
2316
- $("#closeAddMemory")?.addEventListener("click", () =>
2317
- $("#addMemoryModal")?.classList.add("hidden"),
2318
- );
2319
- $("#cancelAddMemory")?.addEventListener("click", () =>
2320
- $("#addMemoryModal")?.classList.add("hidden"),
2321
- );
2322
-
2323
- $("#confirmAddMemory")?.addEventListener("click", async () => {
2324
- const content = $("#newMemoryContent")?.value?.trim();
2325
- if (!content) {
2326
- toast("Content is required", "error");
2327
- return;
2328
- }
2329
- const category = $("#newMemoryCategory")?.value || "episodic";
2330
- const importance = parseInt($("#newMemoryImportance")?.value) || 5;
2331
- try {
2332
- await api("/memory/memories", {
2333
- method: "POST",
2334
- body: { content, category, importance },
2335
- });
2336
- $("#addMemoryModal")?.classList.add("hidden");
2337
- $("#newMemoryContent").value = "";
2338
- await _loadMemoriesTab(_memActiveCategory);
2339
- toast("Memory saved", "success");
2340
- } catch {
2341
- toast("Failed to save memory", "error");
2342
- }
2343
- });
2344
-
2345
- // Set core memory key
2346
- $("#setCoreBtn")?.addEventListener("click", async () => {
2347
- const key = $("#coreKeySelect")?.value;
2348
- const value = $("#coreValueInput")?.value?.trim();
2349
- if (!key || !value) {
2350
- toast("Key and value are required", "error");
2351
- return;
2352
- }
2353
- try {
2354
- await api(`/memory/core/${key}`, { method: "PUT", body: { value } });
2355
- $("#coreValueInput").value = "";
2356
- const core = await api("/memory/core");
2357
- _renderCoreMemory(core);
2358
- toast("Core memory updated", "success");
2359
- } catch {
2360
- toast("Failed to update core memory", "error");
2361
- }
2362
- });
2363
-
2364
- // API Keys
2365
- window.deleteApiKey = async (name) => {
2366
- try {
2367
- await api(`/memory/api-keys/${name}`, { method: "DELETE" });
2368
- loadMemoryPage();
2369
- toast("Key deleted", "success");
2370
- } catch {
2371
- toast("Failed to delete", "error");
2372
- }
2373
- };
2374
-
2375
- $("#addApiKeyBtn")?.addEventListener("click", () => {
2376
- const name = prompt("Service name:");
2377
- if (!name) return;
2378
- const key = prompt("API key value:");
2379
- if (!key) return;
2380
- api(`/memory/api-keys/${name}`, { method: "PUT", body: { key } })
2381
- .then(() => {
2382
- loadMemoryPage();
2383
- toast("Key added", "success");
2384
- })
2385
- .catch(() => toast("Failed to add key", "error"));
2386
- });
2387
-
2388
- // Global click delegation for memory actions
2389
- document.addEventListener("click", async (e) => {
2390
- const btn = e.target.closest("[data-action]");
2391
- if (!btn) return;
2392
- const action = btn.dataset.action;
2393
-
2394
- if (action === "deleteApiKey") {
2395
- window.deleteApiKey(btn.dataset.name);
2396
- } else if (action === "deleteMemory") {
2397
- if (!confirm("Delete this memory?")) return;
2398
- try {
2399
- await api(`/memory/memories/${btn.dataset.id}`, { method: "DELETE" });
2400
- await _loadMemoriesTab(_memActiveCategory);
2401
- toast("Memory deleted", "success");
2402
- } catch {
2403
- toast("Failed to delete", "error");
2404
- }
2405
- } else if (action === "editCore") {
2406
- const newVal = prompt(`Edit ${btn.dataset.key}:`, btn.dataset.val);
2407
- if (newVal === null) return;
2408
- try {
2409
- await api(`/memory/core/${btn.dataset.key}`, {
2410
- method: "PUT",
2411
- body: { value: newVal },
2412
- });
2413
- const core = await api("/memory/core");
2414
- _renderCoreMemory(core);
2415
- toast("Updated", "success");
2416
- } catch {
2417
- toast("Failed to update", "error");
2418
- }
2419
- } else if (action === "deleteCore") {
2420
- if (!confirm(`Delete core key "${btn.dataset.key}"?`)) return;
2421
- try {
2422
- await api(`/memory/core/${btn.dataset.key}`, { method: "DELETE" });
2423
- const core = await api("/memory/core");
2424
- _renderCoreMemory(core);
2425
- toast("Deleted", "success");
2426
- } catch {
2427
- toast("Failed to delete", "error");
2428
- }
2429
- }
2430
- });
2431
-
2432
- // ── Skills Page ──
2433
-
2434
- // Tab switching for skills page
2435
- document.querySelectorAll("[data-skills-tab]").forEach((tab) => {
2436
- tab.addEventListener("click", () => {
2437
- document
2438
- .querySelectorAll("[data-skills-tab]")
2439
- .forEach((t) => t.classList.remove("active"));
2440
- tab.classList.add("active");
2441
- const which = tab.dataset.skillsTab;
2442
- $("#skillList").classList.toggle("hidden", which !== "installed");
2443
- $("#skillStore").classList.toggle("hidden", which !== "store");
2444
- if (which === "store") loadSkillStore();
2445
- else loadSkillsPage();
2446
- });
2447
- });
2448
-
2449
- async function loadSkillStore(options = {}) {
2450
- const wrap = $("#skillStore");
2451
- const pageBody = wrap.closest(".page-body");
2452
- const shouldPreserveState = !!options.preserveState;
2453
- const previousState = {
2454
- filter: wrap.dataset.storeFilter || "",
2455
- pageScrollTop: pageBody ? pageBody.scrollTop : 0,
2456
- panelScrollTop: wrap.scrollTop || 0,
2457
- };
2458
-
2459
- wrap.innerHTML = '<div class="empty-state"><p>Loading store…</p></div>';
2460
- try {
2461
- const items = await api("/store");
2462
-
2463
- // Build category groups
2464
- const cats = {};
2465
- for (const item of items) {
2466
- if (!cats[item.category]) cats[item.category] = [];
2467
- cats[item.category].push(item);
2468
- }
2469
-
2470
- const CAT_LABELS = {
2471
- system: "⚙️ System",
2472
- network: "📡 Network",
2473
- info: "ℹ️ Info",
2474
- dev: "🛠 Dev",
2475
- productivity: "🗂 Productivity",
2476
- fun: "🎲 Fun",
2477
- maker: "🖨️ Maker",
2478
- };
2479
-
2480
- wrap.innerHTML = "";
2481
-
2482
- // Search input
2483
- const searchRow = document.createElement("div");
2484
- searchRow.style.cssText = "margin-bottom:16px;";
2485
- const searchInp = document.createElement("input");
2486
- searchInp.type = "text";
2487
- searchInp.className = "input";
2488
- searchInp.placeholder = "Search skills…";
2489
- searchInp.value = previousState.filter;
2490
- searchRow.appendChild(searchInp);
2491
- wrap.appendChild(searchRow);
2492
-
2493
- const cardsWrap = document.createElement("div");
2494
- wrap.appendChild(cardsWrap);
2495
-
2496
- function renderStore(filter) {
2497
- cardsWrap.innerHTML = "";
2498
- let totalShown = 0;
2499
- for (const [cat, catItems] of Object.entries(cats)) {
2500
- const visible = catItems.filter(
2501
- (i) =>
2502
- !filter ||
2503
- i.name.toLowerCase().includes(filter) ||
2504
- i.description.toLowerCase().includes(filter),
2505
- );
2506
- if (!visible.length) continue;
2507
- totalShown += visible.length;
2508
-
2509
- const section = document.createElement("div");
2510
- section.style.cssText = "margin-bottom:28px;";
2511
- section.innerHTML = `<div style="font-size:0.8rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-muted);margin-bottom:10px;">${CAT_LABELS[cat] || cat}</div>`;
2512
-
2513
- const grid = document.createElement("div");
2514
- grid.style.cssText =
2515
- "display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:10px;";
2516
-
2517
- for (const item of visible) {
2518
- const card = document.createElement("div");
2519
- card.className = "card";
2520
- card.style.cssText =
2521
- "display:flex;flex-direction:column;gap:8px;padding:14px;";
2522
- card.innerHTML = `
2523
- <div style="display:flex;align-items:center;gap:10px;">
2524
- <span style="font-size:1.6rem;line-height:1;">${item.icon}</span>
2525
- <div style="flex:1;min-width:0;">
2526
- <div style="font-weight:600;font-size:0.95rem;">${escapeHtml(item.name)}</div>
2527
- <div style="font-size:0.78rem;color:var(--text-muted);margin-top:2px;">${escapeHtml(item.description)}</div>
2528
- </div>
2529
- </div>
2530
- <div style="display:flex;justify-content:flex-end;">
2531
- ${item.installed
2532
- ? `<span class="badge badge-success" style="margin-right:auto;">Installed</span>
2533
- <button class="btn btn-sm btn-danger" data-store-action="uninstall" data-store-id="${escapeHtml(item.id)}">Remove</button>`
2534
- : `<button class="btn btn-sm btn-primary" data-store-action="install" data-store-id="${escapeHtml(item.id)}">Install</button>`
2535
- }
2536
- </div>`;
2537
- grid.appendChild(card);
2538
- }
2539
- section.appendChild(grid);
2540
- cardsWrap.appendChild(section);
2541
- }
2542
- if (!totalShown)
2543
- cardsWrap.innerHTML =
2544
- '<div class="empty-state"><p>No matching skills</p></div>';
2545
- }
2546
-
2547
- renderStore(previousState.filter.trim().toLowerCase());
2548
-
2549
- searchInp.addEventListener("input", () => {
2550
- wrap.dataset.storeFilter = searchInp.value;
2551
- renderStore(searchInp.value.trim().toLowerCase());
2552
- });
2553
-
2554
- cardsWrap.addEventListener("click", async (e) => {
2555
- const btn = e.target.closest("[data-store-action]");
2556
- if (!btn) return;
2557
- const { storeAction, storeId } = btn.dataset;
2558
- btn.disabled = true;
2559
- btn.textContent = storeAction === "install" ? "Installing…" : "Removing…";
2560
- try {
2561
- if (storeAction === "install") {
2562
- await api(`/store/${storeId}/install`, { method: "POST" });
2563
- toast("Skill installed!", "success");
2564
- } else {
2565
- await api(`/store/${storeId}/uninstall`, { method: "DELETE" });
2566
- toast("Skill removed", "info");
2567
- }
2568
- await loadSkillStore({ preserveState: true }); // refresh without jumping back to top
2569
- } catch (err) {
2570
- toast("Error: " + err.message, "error");
2571
- btn.disabled = false;
2572
- }
2573
- });
2574
-
2575
- if (shouldPreserveState) {
2576
- requestAnimationFrame(() => {
2577
- if (pageBody) pageBody.scrollTop = previousState.pageScrollTop;
2578
- wrap.scrollTop = previousState.panelScrollTop;
2579
- });
2580
- }
2581
- } catch (err) {
2582
- wrap.innerHTML =
2583
- '<div class="empty-state"><p>Failed to load store</p></div>';
2584
- console.error(err);
2585
- }
2586
- }
2587
-
2588
- async function loadSkillsPage() {
2589
- try {
2590
- const skills = await api("/skills");
2591
- const container = $("#skillList");
2592
- container.innerHTML = "";
2593
-
2594
- if (skills.length === 0) {
2595
- container.innerHTML =
2596
- '<div class="empty-state"><p>No skills installed yet. <a href="#" id="goToStore">Browse the store →</a></p></div>';
2597
- document.getElementById("goToStore")?.addEventListener("click", (e) => {
2598
- e.preventDefault();
2599
- document.querySelector('[data-skills-tab="store"]')?.click();
2600
- });
2601
- return;
2602
- }
2603
-
2604
- for (const skill of skills) {
2605
- const card = document.createElement("div");
2606
- card.className = "item-card";
2607
- const badges = [
2608
- `<span class="badge ${skill.enabled ? "badge-success" : "badge-neutral"}">${skill.enabled ? "Active" : "Disabled"}</span>`,
2609
- ];
2610
- if (skill.draft) badges.push('<span class="badge badge-warning">Draft</span>');
2611
- if (skill.autoCreated) badges.push('<span class="badge badge-info">Auto-learned</span>');
2612
- card.innerHTML = `
2613
- <div class="item-card-header">
2614
- <div>
2615
- <div class="item-card-title">${escapeHtml(skill.name)}</div>
2616
- <div class="item-card-meta">${escapeHtml(skill.description)}</div>
2617
- </div>
2618
- <div class="item-card-actions">
2619
- ${badges.join("")}
2620
- <button class="btn btn-sm btn-secondary" data-action="toggleSkill" data-name="${escapeHtml(skill.name)}" data-enabled="${skill.enabled ? "true" : "false"}">${skill.enabled ? "Disable" : "Enable"}</button>
2621
- <button class="btn btn-sm btn-secondary" data-action="editSkill" data-name="${escapeHtml(skill.name)}">Edit</button>
2622
- <button class="btn btn-sm btn-danger" data-action="deleteSkill" data-name="${escapeHtml(skill.name)}">&times;</button>
2623
- </div>
2624
- </div>
2625
- <div class="item-card-meta">Trigger: ${escapeHtml(skill.trigger || "N/A")} | Category: ${escapeHtml(skill.category)} | Source: ${escapeHtml(skill.source || "local")}</div>
2626
- <div class="item-card-meta" style="margin-top:6px;">${escapeHtml(skill.filePath || "")}</div>
2627
- `;
2628
- container.appendChild(card);
2629
- }
2630
- } catch (err) {
2631
- toast("Failed to load skills", "error");
2632
- }
2633
- }
2634
-
2635
- window.editSkill = async (name) => {
2636
- try {
2637
- const data = await api(`/skills/${name}`);
2638
- const content = prompt("Edit skill content:", data.content);
2639
- if (content !== null) {
2640
- await api(`/skills/${name}`, { method: "PUT", body: { content } });
2641
- loadSkillsPage();
2642
- toast("Skill updated", "success");
2643
- }
2644
- } catch (err) {
2645
- toast("Failed to edit skill", "error");
2646
- }
2647
- };
2648
-
2649
- window.deleteSkill = async (name) => {
2650
- if (!confirm(`Delete skill ${name}?`)) return;
2651
- try {
2652
- await api(`/skills/${name}`, { method: "DELETE" });
2653
- loadSkillsPage();
2654
- toast("Skill deleted", "success");
2655
- } catch (err) {
2656
- toast("Failed to delete", "error");
2657
- }
2658
- };
2659
-
2660
- window.toggleSkill = async (name, enabled) => {
2661
- try {
2662
- await api(`/skills/${name}`, {
2663
- method: "PUT",
2664
- body: { enabled },
2665
- });
2666
- loadSkillsPage();
2667
- toast(enabled ? "Skill enabled" : "Skill disabled", "success");
2668
- } catch (err) {
2669
- toast("Failed to update skill", "error");
2670
- }
2671
- };
2672
-
2673
- // Skills event delegation
2674
- $("#skillList").addEventListener("click", (e) => {
2675
- const btn = e.target.closest("[data-action]");
2676
- if (!btn) return;
2677
- const action = btn.dataset.action;
2678
- if (action === "editSkill") window.editSkill(btn.dataset.name);
2679
- else if (action === "deleteSkill") window.deleteSkill(btn.dataset.name);
2680
- else if (action === "toggleSkill")
2681
- window.toggleSkill(btn.dataset.name, btn.dataset.enabled !== "true");
2682
- });
2683
-
2684
- $("#addSkillBtn").addEventListener("click", () => {
2685
- const name = prompt("Skill filename (without .md):");
2686
- if (!name) return;
2687
- const content = `---\nname: ${name}\ndescription: \ntrigger: \ncategory: general\nenabled: true\n---\n\n# ${name}\n\nDescribe the skill here.`;
2688
- api("/skills", { method: "POST", body: { filename: name, content } })
2689
- .then(() => {
2690
- loadSkillsPage();
2691
- toast("Skill created", "success");
2692
- })
2693
- .catch(() => toast("Failed to create skill", "error"));
2694
- });
2695
-
2696
- // ── MCP Servers Page ──
2697
-
2698
- async function loadMCPPage() {
2699
- try {
2700
- const servers = await api("/mcp");
2701
- const container = $("#mcpServerList");
2702
- container.innerHTML = "";
2703
-
2704
- if (servers.length === 0) {
2705
- container.innerHTML =
2706
- '<div class="empty-state"><p>No MCP servers configured</p></div>';
2707
- return;
2708
- }
2709
-
2710
- for (const srv of servers) {
2711
- const card = document.createElement("div");
2712
- card.className = "item-card";
2713
- card.innerHTML = `
2714
- <div class="item-card-header">
2715
- <div>
2716
- <div class="item-card-title">${escapeHtml(srv.name)}</div>
2717
- <div class="item-card-meta font-mono">${escapeHtml(srv.command)}</div>
2718
- </div>
2719
- <div class="item-card-actions">
2720
- <span class="badge ${srv.status === "running" ? "badge-success" : "badge-neutral"}">${srv.status}</span>
2721
- ${srv.status === "running"
2722
- ? `<button class="btn btn-sm btn-secondary" data-action="stopMCP" data-id="${srv.id}">Stop</button>`
2723
- : `<button class="btn btn-sm btn-primary" data-action="startMCP" data-id="${srv.id}">Start</button>`
2724
- }
2725
- ${srv.config?.auth?.type === "oauth" ? `<button class="btn btn-sm btn-primary" data-action="loginMCP" data-id="${srv.id}">Login</button>` : ""}
2726
- <button class="btn btn-sm btn-secondary" data-action="editMCP" data-id="${srv.id}" data-name="${escapeHtml(srv.name)}" data-url="${escapeHtml(srv.command)}" data-config='${escapeHtml(JSON.stringify(srv.config || {}))}'>Edit</button>
2727
- <button class="btn btn-sm btn-danger" data-action="deleteMCP" data-id="${srv.id}">&times;</button>
2728
- </div>
2729
- </div>
2730
- ${srv.toolCount > 0 ? `<div class="item-card-meta">${srv.toolCount} tools available</div>` : ""}
2731
- `;
2732
- container.appendChild(card);
2733
- }
2734
- } catch (err) {
2735
- toast("Failed to load MCP servers", "error");
2736
- }
2737
- }
2738
-
2739
- window.startMCP = async (id) => {
2740
- try {
2741
- await api(`/mcp/${id}/start`, { method: "POST" });
2742
- loadMCPPage();
2743
- toast("Server started", "success");
2744
- } catch (err) {
2745
- toast(err.message, "error");
2746
- }
2747
- };
2748
-
2749
- window.stopMCP = async (id) => {
2750
- try {
2751
- await api(`/mcp/${id}/stop`, { method: "POST" });
2752
- loadMCPPage();
2753
- toast("Server stopped", "success");
2754
- } catch (err) {
2755
- toast(err.message, "error");
2756
- }
2757
- };
2758
-
2759
- window.deleteMCP = async (id) => {
2760
- if (!confirm("Delete this MCP server?")) return;
2761
- try {
2762
- await api(`/mcp/${id}`, { method: "DELETE" });
2763
- loadMCPPage();
2764
- toast("Server deleted", "success");
2765
- } catch (err) {
2766
- toast("Failed to delete", "error");
2767
- }
2768
- };
2769
-
2770
- // MCP event delegation
2771
- $("#mcpServerList").addEventListener("click", (e) => {
2772
- const btn = e.target.closest("[data-action]");
2773
- if (!btn) return;
2774
- const id = btn.dataset.id;
2775
- const action = btn.dataset.action;
2776
- if (action === "startMCP") window.startMCP(id);
2777
- else if (action === "stopMCP") window.stopMCP(id);
2778
- else if (action === "deleteMCP") window.deleteMCP(id);
2779
- else if (action === "loginMCP") {
2780
- const w = window.open(
2781
- `/api/mcp/${id}/start`,
2782
- "oauth",
2783
- "width=600,height=700",
2784
- );
2785
- // We expect the server to return 302 or JSON with `{status: 'oauth_redirect', url}` for a normal GET/POST
2786
- // Let's first make an API call to get the URL, then window.open that URL
2787
- api(`/mcp/${id}/start`, { method: "POST" })
2788
- .then((res) => {
2789
- if (res.status === "oauth_redirect") {
2790
- window.open(res.url, "oauth", "width=600,height=700");
2791
- } else {
2792
- toast("Server started without needing login", "success");
2793
- loadMCPPage();
2794
- }
2795
- })
2796
- .catch((err) => toast("Login failed: " + err.message, "error"));
2797
- } else if (action === "editMCP") {
2798
- $("#mcpModalTitle").textContent = "Edit MCP Server";
2799
- $("#mcpName").value = btn.dataset.name;
2800
- $("#mcpUrl").value = btn.dataset.url;
2801
- $("#mcpModal").dataset.id = id;
2802
-
2803
- // Auth fields
2804
- const config = JSON.parse(btn.dataset.config || "{}");
2805
- const auth = config.auth || {};
2806
- $("#mcpAuthType").value = auth.type || "none";
2807
- $("#mcpAuthToken").value = auth.token || "";
2808
- $("#mcpAuthClientId").value = auth.clientId || "";
2809
- $("#mcpAuthServerUrl").value = auth.authServerUrl || "";
2810
- updateMcpAuthFields();
2811
-
2812
- $("#mcpModal").classList.remove("hidden");
2813
- }
2814
- });
2815
-
2816
- function updateMcpAuthFields() {
2817
- const type = $("#mcpAuthType").value;
2818
-
2819
- if (type === "bearer") {
2820
- $("#mcpAuthBearerGroup").classList.remove("hidden");
2821
- $("#mcpAuthOauthGroup").classList.add("hidden");
2822
- } else if (type === "oauth") {
2823
- $("#mcpAuthBearerGroup").classList.add("hidden");
2824
- $("#mcpAuthOauthGroup").classList.remove("hidden");
2825
- } else {
2826
- $("#mcpAuthBearerGroup").classList.add("hidden");
2827
- $("#mcpAuthOauthGroup").classList.add("hidden");
2828
- }
2829
- }
2830
-
2831
- $("#mcpAuthType").addEventListener("change", updateMcpAuthFields);
2832
- $("#mcpAuthType").addEventListener("input", updateMcpAuthFields);
2833
-
2834
- $("#addMcpBtn").addEventListener("click", () => {
2835
- $("#mcpName").value = "";
2836
- $("#mcpUrl").value = "";
2837
- $("#mcpAuthType").value = "none";
2838
- $("#mcpAuthToken").value = "";
2839
- $("#mcpAuthClientId").value = "";
2840
- $("#mcpAuthServerUrl").value = "";
2841
- updateMcpAuthFields();
2842
-
2843
- $("#mcpModalTitle").textContent = "Add MCP Server";
2844
- $("#mcpModal").dataset.id = "";
2845
- $("#mcpModal").classList.remove("hidden");
2846
- });
2847
-
2848
- $("#closeMcpModal").addEventListener("click", () =>
2849
- $("#mcpModal").classList.add("hidden"),
2850
- );
2851
- $("#cancelMcpModal").addEventListener("click", () =>
2852
- $("#mcpModal").classList.add("hidden"),
2853
- );
2854
-
2855
- $("#saveMcpBtn").addEventListener("click", () => {
2856
- const name = $("#mcpName").value.trim();
2857
- const url = $("#mcpUrl").value.trim();
2858
- if (!name || !url) {
2859
- toast("Name and URL are required", "error");
2860
- return;
2861
- }
2862
-
2863
- const id = $("#mcpModal").dataset.id;
2864
- const method = id ? "PUT" : "POST";
2865
- const endpoint = id ? `/mcp/${id}` : "/mcp";
2866
-
2867
- const authType = $("#mcpAuthType").value;
2868
- const auth = { type: authType };
2869
- if (authType === "bearer") auth.token = $("#mcpAuthToken").value.trim();
2870
- if (authType === "oauth") {
2871
- auth.clientId = $("#mcpAuthClientId").value.trim();
2872
- auth.authServerUrl = $("#mcpAuthServerUrl").value.trim();
2873
- }
2874
-
2875
- api(endpoint, {
2876
- method,
2877
- body: { name, command: url, config: { auth }, enabled: true },
2878
- })
2879
- .then(() => {
2880
- loadMCPPage();
2881
- $("#mcpModal").classList.add("hidden");
2882
- toast(id ? "Server updated" : "Server added", "success");
2883
- })
2884
- .catch((err) => toast("Failed to save server: " + err.message, "error"));
2885
- });
2886
-
2887
- // Listen for popup messages to refresh auth
2888
- window.addEventListener("message", (e) => {
2889
- if (e.data?.type === "mcp_oauth_success") {
2890
- toast("OAuth authentication successful!", "success");
2891
- loadMCPPage();
2892
- }
2893
- });
2894
-
2895
- // ── Scheduler Page ──
2896
-
2897
- async function loadSchedulerPage() {
2898
- try {
2899
- const tasks = await api("/scheduler");
2900
- const container = $("#taskList");
2901
- container.innerHTML = "";
2902
-
2903
- if (tasks.length === 0) {
2904
- container.innerHTML =
2905
- '<div class="empty-state"><p>No scheduled tasks</p></div>';
2906
- return;
2907
- }
2908
-
2909
- for (const task of tasks) {
2910
- const card = document.createElement("div");
2911
- card.className = "item-card";
2912
- card.innerHTML = `
2913
- <div class="item-card-header">
2914
- <div>
2915
- <div class="item-card-title">${escapeHtml(task.name)}</div>
2916
- <div class="item-card-meta font-mono">${escapeHtml(task.cronExpression)}</div>
2917
- </div>
2918
- <div class="item-card-actions">
2919
- <span class="badge ${task.enabled ? "badge-success" : "badge-neutral"}">${task.enabled ? "Active" : "Paused"}</span>
2920
- <button class="btn btn-sm btn-primary" data-action="runTask" data-id="${task.id}">Run Now</button>
2921
- <button class="btn btn-sm btn-danger" data-action="deleteTask" data-id="${task.id}">&times;</button>
2922
- </div>
2923
- </div>
2924
- <div class="item-card-meta">${escapeHtml(task.config?.prompt?.slice(0, 100) || "No prompt")}${task.lastRun ? ` | Last run: ${formatTime(task.lastRun)}` : ""}</div>
2925
- `;
2926
- container.appendChild(card);
2927
- }
2928
- } catch (err) {
2929
- toast("Failed to load tasks", "error");
2930
- }
2931
- }
2932
-
2933
- window.runTask = async (id) => {
2934
- try {
2935
- await api(`/scheduler/${id}/run`, { method: "POST" });
2936
- toast("Task started", "success");
2937
- } catch (err) {
2938
- toast(err.message, "error");
2939
- }
2940
- };
2941
-
2942
- window.deleteTask = async (id) => {
2943
- if (!confirm("Delete this task?")) return;
2944
- try {
2945
- await api(`/scheduler/${id}`, { method: "DELETE" });
2946
- loadSchedulerPage();
2947
- toast("Task deleted", "success");
2948
- } catch (err) {
2949
- toast("Failed to delete", "error");
2950
- }
2951
- };
2952
-
2953
- // Scheduler event delegation
2954
- $("#taskList").addEventListener("click", (e) => {
2955
- const btn = e.target.closest("[data-action]");
2956
- if (!btn) return;
2957
- const id = btn.dataset.id;
2958
- const action = btn.dataset.action;
2959
- if (action === "runTask") window.runTask(id);
2960
- else if (action === "deleteTask") window.deleteTask(id);
2961
- });
2962
-
2963
- $("#addTaskBtn").addEventListener("click", () => {
2964
- const name = prompt("Task name:");
2965
- if (!name) return;
2966
- const cronExpression = prompt(
2967
- "Cron expression (e.g., */30 * * * * for every 30 min):",
2968
- );
2969
- if (!cronExpression) return;
2970
- const promptText = prompt("What should the agent do?");
2971
- if (!promptText) return;
2972
-
2973
- api("/scheduler", {
2974
- method: "POST",
2975
- body: { name, cronExpression, prompt: promptText },
2976
- })
2977
- .then(() => {
2978
- loadSchedulerPage();
2979
- toast("Task created", "success");
2980
- })
2981
- .catch((err) => toast(err.message, "error"));
2982
- });
2983
-
2984
- // ── Messaging Page ──
2985
-
2986
- // Registry of supported platforms — add new entries here to support more providers
2987
- const _svgLogo = {
2988
- whatsapp: `<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="18" cy="18" r="18" fill="#25D366"/><path d="M18 7C11.9 7 7 11.9 7 18c0 2.1.58 4.08 1.6 5.77L7 29l5.4-1.56A11 11 0 0018 29c6.07 0 11-4.93 11-11S24.07 7 18 7z" fill="#25D366"/><path d="M24.4 21.52c-.33-.17-1.94-.96-2.24-1.07-.3-.1-.52-.17-.74.17-.22.33-.85 1.07-1.04 1.29-.2.22-.38.25-.71.08-.33-.17-1.39-.51-2.65-1.63-.98-.87-1.64-1.95-1.83-2.28-.19-.33-.02-.51.14-.67.15-.15.33-.38.5-.58.17-.19.22-.33.33-.55.1-.22.05-.41-.03-.58-.08-.17-.74-1.78-1.01-2.44-.27-.64-.54-.55-.74-.56-.19-.01-.41-.01-.63-.01-.22 0-.58.08-.88.41-.3.33-1.15 1.12-1.15 2.74s1.18 3.18 1.34 3.4c.17.22 2.32 3.54 5.61 4.96.79.34 1.4.54 1.87.69.79.25 1.5.22 2.07.13.63-.09 1.94-.79 2.22-1.56.28-.77.28-1.43.19-1.56-.09-.14-.3-.22-.63-.38z" fill="white"/></svg>`,
2989
-
2990
- telegram: `<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="18" cy="18" r="18" fill="#2AABEE"/><path d="M8.16 17.36l14.75-5.69c.68-.25 1.28.17 1.14.95L21.55 24.4c-.18.81-.67 1.01-1.36.63l-3.83-2.83-1.85 1.78c-.2.2-.38.37-.77.37l.27-3.86 6.99-6.32c.3-.27-.07-.42-.46-.15l-8.65 5.45-3.72-1.17c-.81-.25-.82-.81.17-1.2z" fill="white"/></svg>`,
2991
-
2992
- discord: `<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="18" cy="18" r="18" fill="#5865F2"/><path d="M25.57 11.69A18.2 18.2 0 0021.8 10.6a.07.07 0 00-.07.04 12.4 12.4 0 00-.52 1.06 16.8 16.8 0 00-5.07 0 10.7 10.7 0 00-.53-1.06.07.07 0 00-.07-.04 18.1 18.1 0 00-3.59 1.1.06.06 0 00-.03.03C9.51 15.52 8.61 19 9.07 22.4c0 .02.01.03.03.04a17.3 17.3 0 005.22 2.64.07.07 0 00.07-.02c.4-.55.76-1.13 1.06-1.74a.07.07 0 00-.04-.09 11.4 11.4 0 01-1.63-.78.07.07 0 010-.11c.11-.08.22-.17.32-.25a.07.07 0 01.07-.01c3.42 1.56 7.12 1.56 10.5 0a.07.07 0 01.07.01c.1.08.21.17.33.25a.07.07 0 010 .11c-.52.3-1.06.56-1.64.78a.07.07 0 00-.03.1c.31.6.67 1.18 1.06 1.74a.07.07 0 00.07.02 17.24 17.24 0 005.23-2.64.07.07 0 00.03-.04c.52-3.74-.53-6.93-2.85-10.38a.05.05 0 00-.03-.02zm-9.73 6.72c-1.1 0-2-1-2-2.24s.88-2.24 2-2.24c1.12 0 2.01 1.01 2 2.24 0 1.23-.88 2.24-2 2.24zm7.37 0c-1.1 0-2-1-2-2.24s.88-2.24 2-2.24c1.12 0 2.01 1.01 2 2.24 0 1.23-.88 2.24-2 2.24z" fill="white"/></svg>`,
2993
-
2994
- telnyx: `<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="18" cy="18" r="18" fill="#00C8A0"/><path d="M23 21.83c-.56.56-1.12 1.12-2.02 1.01-.9-.11-2.47-.79-4.38-2.7-1.91-1.91-2.59-3.48-2.7-4.38-.11-.9.45-1.46 1.01-2.02.56-.56.9-.56 1.24 0l1.35 2.02c.34.56.22 1.01-.11 1.35l-.56.56c.34.67.9 1.46 1.58 2.13.67.67 1.46 1.23 2.13 1.57l.56-.56c.34-.34.79-.45 1.35-.11l2.02 1.35c.56.34.56.68 0 1.24z" fill="white"/><path d="M18 9v2.25A6.75 6.75 0 0124.75 18H27A9 9 0 0018 9z" fill="white" opacity=".65"/><path d="M18 12.75v2.25A3 3 0 0121 18h2.25A5.25 5.25 0 0018 12.75z" fill="white" opacity=".9"/></svg>`,
2995
- };
2996
-
2997
- const MESSAGING_PLATFORM_GROUPS = [
2998
- {
2999
- id: "text",
3000
- label: "Text & Chat",
3001
- description: "Send and receive messages",
3002
- },
3003
- {
3004
- id: "voice",
3005
- label: "Voice Calls",
3006
- description: "Inbound & outbound phone calls",
3007
- },
3008
- ];
3009
-
3010
- const MESSAGING_PLATFORMS = [
3011
- {
3012
- id: "whatsapp",
3013
- name: "WhatsApp",
3014
- group: "text",
3015
- color: "#25D366",
3016
- connectMethod: "qr",
3017
- },
3018
- {
3019
- id: "telegram",
3020
- name: "Telegram",
3021
- group: "text",
3022
- color: "#2AABEE",
3023
- connectMethod: "config",
3024
- },
3025
- {
3026
- id: "discord",
3027
- name: "Discord",
3028
- group: "text",
3029
- color: "#5865F2",
3030
- connectMethod: "config",
3031
- },
3032
- {
3033
- id: "telnyx",
3034
- name: "Telnyx Voice",
3035
- group: "voice",
3036
- color: "#00C8A0",
3037
- connectMethod: "config",
3038
- },
3039
- ];
3040
-
3041
- function normalizeWhatsAppWhitelistEntry(value) {
3042
- const raw = String(value || "").trim().toLowerCase();
3043
- if (!raw) return "";
3044
- const base = raw.includes("@") ? raw.split("@")[0] : raw;
3045
- const primary = base.includes(":") ? base.split(":")[0] : base;
3046
- const digits = primary.replace(/\D/g, "");
3047
- return digits || primary;
3048
- }
3049
-
3050
- function normalizeWhatsAppWhitelist(list) {
3051
- const seen = new Set();
3052
- const normalized = [];
3053
- for (const entry of Array.isArray(list) ? list : []) {
3054
- const value = normalizeWhatsAppWhitelistEntry(entry);
3055
- if (!value || seen.has(value)) continue;
3056
- seen.add(value);
3057
- normalized.push(value);
3058
- }
3059
- return normalized;
3060
- }
3061
-
3062
- // Per-platform whitelist config
3063
- const PLATFORM_WHITELIST = {
3064
- whatsapp: {
3065
- settingKey: "platform_whitelist_whatsapp",
3066
- label: "Approved contacts",
3067
- emptyHint:
3068
- "No approved contacts yet — senders are added via the allow popup.",
3069
- allowAdd: false,
3070
- saveFn: async (list) =>
3071
- api("/settings", {
3072
- method: "PUT",
3073
- body: {
3074
- platform_whitelist_whatsapp: JSON.stringify(
3075
- normalizeWhatsAppWhitelist(list),
3076
- ),
3077
- },
3078
- }),
3079
- },
3080
- telnyx: {
3081
- settingKey: "platform_whitelist_telnyx",
3082
- label: "Allowed callers",
3083
- emptyHint:
3084
- "Empty — all inbound callers blocked (or gated via secret code if set).",
3085
- allowAdd: true,
3086
- addPlaceholder: "e.g. +12125550100",
3087
- saveFn: async (list) =>
3088
- api("/messaging/telnyx/whitelist", {
3089
- method: "PUT",
3090
- body: { numbers: list },
3091
- }),
3092
- },
3093
- discord: {
3094
- settingKey: "platform_whitelist_discord",
3095
- label: "Approved users, servers & channels",
3096
- emptyHint:
3097
- "No entries — all messages blocked. Add entries via the allow popup or manually below.",
3098
- allowAdd: true,
3099
- addTypes: ["user", "guild", "channel"],
3100
- saveFn: async (list) =>
3101
- api("/messaging/discord/whitelist", {
3102
- method: "PUT",
3103
- body: { ids: list },
3104
- }),
3105
- },
3106
- telegram: {
3107
- settingKey: "platform_whitelist_telegram",
3108
- label: "Approved users & groups",
3109
- emptyHint:
3110
- "No entries — all messages blocked. Add entries via the allow popup or manually below.",
3111
- allowAdd: true,
3112
- addTypes: ["user", "group"],
3113
- saveFn: async (list) =>
3114
- api("/messaging/telegram/whitelist", {
3115
- method: "PUT",
3116
- body: { ids: list },
3117
- }),
3118
- },
3119
- };
3120
-
3121
- async function loadMessagingPage() {
3122
- try {
3123
- const [statuses, settings] = await Promise.all([
3124
- api("/messaging/status"),
3125
- api("/settings"),
3126
- ]);
3127
- const container = $("#platformList");
3128
- container.innerHTML = "";
3129
-
3130
- for (const group of MESSAGING_PLATFORM_GROUPS) {
3131
- const groupPlatforms = MESSAGING_PLATFORMS.filter(
3132
- (p) => p.group === group.id,
3133
- );
3134
-
3135
- // Section header
3136
- const section = document.createElement("div");
3137
- section.style.cssText = "margin-bottom:28px;";
3138
-
3139
- const heading = document.createElement("div");
3140
- heading.style.cssText =
3141
- "display:flex;align-items:baseline;gap:10px;margin-bottom:14px;padding-bottom:8px;border-bottom:1px solid var(--border);";
3142
- heading.innerHTML = `
3143
- <span style="font-size:0.95rem;font-weight:700;">${escapeHtml(group.label)}</span>
3144
- <span style="font-size:0.78rem;color:var(--text-muted);">${escapeHtml(group.description)}</span>`;
3145
- section.appendChild(heading);
3146
-
3147
- // Grid — 2 cols for text/chat, single col for voice
3148
- const grid = document.createElement("div");
3149
- grid.style.cssText =
3150
- group.id === "text"
3151
- ? "display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px;"
3152
- : "display:flex;flex-direction:column;gap:14px;";
3153
-
3154
- for (const platform of groupPlatforms) {
3155
- const info = statuses[platform.id] || { status: "not_configured" };
3156
- const wlCfg = PLATFORM_WHITELIST[platform.id];
3157
- const isConnected = info.status === "connected";
3158
- const isConnecting =
3159
- info.status === "connecting" || info.status === "awaiting_qr";
3160
-
3161
- let wlList = [];
3162
- try {
3163
- const raw = settings[wlCfg.settingKey];
3164
- if (raw) {
3165
- wlList = typeof raw === "string" ? JSON.parse(raw) : raw;
3166
- }
3167
- if (!Array.isArray(wlList)) wlList = [];
3168
- } catch {
3169
- wlList = [];
3170
- }
3171
-
3172
- // Auth subtitle
3173
- let authSub = "";
3174
- if (isConnected) {
3175
- if (info.authInfo?.phoneNumber)
3176
- authSub = escapeHtml(info.authInfo.phoneNumber);
3177
- else if (info.authInfo?.tag) authSub = escapeHtml(info.authInfo.tag);
3178
- else if (info.authInfo?.username)
3179
- authSub = "@" + escapeHtml(info.authInfo.username);
3180
- }
3181
-
3182
- const card = document.createElement("div");
3183
- card.className = "card";
3184
- card.style.cssText = "margin:0;";
3185
-
3186
- // ── Top row: logo + name + status + buttons
3187
- const topRow = document.createElement("div");
3188
- topRow.className = "flex items-center justify-between";
3189
- topRow.innerHTML = `
3190
- <div class="flex items-center gap-3">
3191
- <div style="width:40px;height:40px;flex-shrink:0;border-radius:10px;overflow:hidden;">${_svgLogo[platform.id] || ""}</div>
3192
- <div>
3193
- <div class="item-card-title" style="font-size:0.97rem;">${escapeHtml(platform.name)}</div>
3194
- <div class="flex items-center gap-2 mt-1" style="flex-wrap:wrap;">
3195
- <span class="badge ${isConnected ? "badge-success" : "badge-neutral"}" style="font-size:0.7rem;">
3196
- ${escapeHtml(info.status.replace(/_/g, " "))}
3197
- </span>
3198
- ${authSub ? `<span class="text-xs text-muted">${authSub}</span>` : ""}
3199
- ${!isConnected && info.lastConnected ? `<span class="text-xs text-muted">last seen ${formatTime(info.lastConnected)}</span>` : ""}
3200
- </div>
3201
- </div>
3202
- </div>
3203
- <div class="flex gap-2" style="flex-shrink:0;">
3204
- ${isConnected
3205
- ? `<button class="btn btn-sm btn-secondary" data-action="disconnectPlatform" data-platform="${platform.id}">Disconnect</button>
3206
- <button class="btn btn-sm btn-danger" data-action="logoutPlatform" data-platform="${platform.id}">Logout</button>`
3207
- : isConnecting
3208
- ? `<span class="text-muted text-sm" style="padding:0 4px;">Connecting…</span>`
3209
- : `<button class="btn btn-sm btn-primary" data-action="connectPlatform" data-platform="${platform.id}" data-method="${platform.connectMethod}">Connect</button>`
3210
- }
3211
- </div>`;
3212
- card.appendChild(topRow);
3213
-
3214
- // ── Whitelist collapsible strip
3215
- const strip = document.createElement("div");
3216
- strip.style.cssText =
3217
- "border-top:1px solid var(--border);margin:14px -20px 0;";
3218
-
3219
- const arrowId = `wl-arrow-${platform.id}`;
3220
- const labelId = `wl-label-${platform.id}`;
3221
- const toggleBtn = document.createElement("button");
3222
- toggleBtn.style.cssText =
3223
- "display:flex;align-items:center;gap:7px;width:100%;background:none;border:none;cursor:pointer;padding:9px 20px;color:var(--text-muted);font-size:0.8rem;user-select:none;";
3224
- toggleBtn.innerHTML = `<span id="${arrowId}" style="font-size:0.65rem;transition:transform 0.15s;display:inline-block;">&#9654;</span>
3225
- <span id="${labelId}">${_wlLabel(wlCfg.label, wlList.length)}</span>`;
3226
-
3227
- const panel = document.createElement("div");
3228
- panel.id = `wl-panel-${platform.id}`;
3229
- panel.style.cssText = "display:none;padding:4px 20px 14px;";
3230
- _buildWhitelistPanel(panel, wlList, wlCfg, platform.id);
3231
-
3232
- toggleBtn.addEventListener("click", () => {
3233
- const open = panel.style.display !== "none";
3234
- panel.style.display = open ? "none" : "block";
3235
- document.getElementById(arrowId).style.transform = open
3236
- ? ""
3237
- : "rotate(90deg)";
3238
- });
3239
-
3240
- strip.appendChild(toggleBtn);
3241
- strip.appendChild(panel);
3242
- card.appendChild(strip);
3243
-
3244
- // ── Telnyx-only: voice secret code ─────────────────────────────────
3245
- if (platform.id === "telnyx") {
3246
- const secretStrip = document.createElement("div");
3247
- secretStrip.style.cssText =
3248
- "border-top:1px solid var(--border);margin:0 -20px;";
3249
-
3250
- const secretArrowId = `secret-arrow-telnyx`;
3251
- const secretToggle = document.createElement("button");
3252
- secretToggle.style.cssText =
3253
- "display:flex;align-items:center;gap:7px;width:100%;background:none;border:none;cursor:pointer;padding:9px 20px;color:var(--text-muted);font-size:0.8rem;user-select:none;";
3254
- secretToggle.innerHTML = `<span id="${secretArrowId}" style="font-size:0.65rem;transition:transform 0.15s;display:inline-block;">&#9654;</span>
3255
- <span>Voice secret code</span>`;
3256
-
3257
- const secretPanel = document.createElement("div");
3258
- secretPanel.style.cssText = "display:none;padding:4px 20px 14px;";
3259
-
3260
- const currentSecret = settings["platform_voice_secret_telnyx"] || "";
3261
- secretPanel.innerHTML = `
3262
- <p class="text-xs text-muted" style="margin:0 0 8px;">Digits-only PIN non-whitelisted callers must type within 10 s of calling. Wrong code or timeout bans the number for 10 min. Leave empty to reject all non-whitelisted callers immediately.</p>
3263
- <div style="display:flex;gap:8px;align-items:center;">
3264
- <input id="telnyx-secret-input" type="password" class="input" style="flex:1;max-width:200px;" placeholder="e.g. 1234" value="${escapeHtml(currentSecret)}" autocomplete="off" inputmode="numeric"/>
3265
- <button id="telnyx-secret-save" class="btn btn-primary btn-sm">Save</button>
3266
- <button id="telnyx-secret-clear" class="btn btn-sm btn-secondary">Clear</button>
3267
- </div>`;
3268
-
3269
- secretToggle.addEventListener("click", () => {
3270
- const open = secretPanel.style.display !== "none";
3271
- secretPanel.style.display = open ? "none" : "block";
3272
- document.getElementById(secretArrowId).style.transform = open
3273
- ? ""
3274
- : "rotate(90deg)";
3275
- });
3276
-
3277
- secretPanel.addEventListener("click", async (e) => {
3278
- if (e.target.id === "telnyx-secret-save") {
3279
- const val = document.getElementById("telnyx-secret-input").value;
3280
- try {
3281
- await api("/messaging/telnyx/voice-secret", {
3282
- method: "PUT",
3283
- body: { secret: val },
3284
- });
3285
- toast("Secret code saved", "success");
3286
- } catch {
3287
- toast("Failed to save secret", "error");
3288
- }
3289
- } else if (e.target.id === "telnyx-secret-clear") {
3290
- document.getElementById("telnyx-secret-input").value = "";
3291
- try {
3292
- await api("/messaging/telnyx/voice-secret", {
3293
- method: "PUT",
3294
- body: { secret: "" },
3295
- });
3296
- toast("Secret code cleared", "success");
3297
- } catch {
3298
- toast("Failed to clear secret", "error");
3299
- }
3300
- }
3301
- });
3302
-
3303
- secretStrip.appendChild(secretToggle);
3304
- secretStrip.appendChild(secretPanel);
3305
- card.appendChild(secretStrip);
3306
- }
3307
-
3308
- grid.appendChild(card);
3309
- }
3310
-
3311
- section.appendChild(grid);
3312
- container.appendChild(section);
3313
- }
3314
- } catch (err) {
3315
- console.error(err);
3316
- toast("Failed to load messaging", "error");
3317
- }
3318
- }
3319
-
3320
- function _wlLabel(label, count) {
3321
- return count
3322
- ? `${label} <strong style="color:var(--text);font-weight:600;">(${count})</strong>`
3323
- : `${label} <span style="opacity:0.55;">— none</span>`;
3324
- }
3325
-
3326
- function _buildWhitelistPanel(panel, list, wlCfg, platformId) {
3327
- panel.innerHTML = "";
3328
-
3329
- // Type-badge colours for Discord prefixed entries
3330
- const TYPE_COLORS = {
3331
- user: "#5865F2",
3332
- guild: "#57F287",
3333
- channel: "#FEE75C",
3334
- group: "#2AABEE",
3335
- };
3336
- const TYPE_LABELS = {
3337
- user: "User",
3338
- guild: "Server",
3339
- channel: "Channel",
3340
- group: "Group",
3341
- };
3342
-
3343
- if (!list.length) {
3344
- const empty = document.createElement("p");
3345
- empty.className = "text-xs text-muted";
3346
- empty.style.margin = "0 0 6px";
3347
- empty.textContent = wlCfg.emptyHint;
3348
- panel.appendChild(empty);
3349
- } else {
3350
- const tags = document.createElement("div");
3351
- tags.style.cssText =
3352
- "display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;";
3353
- for (const entry of list) {
3354
- // Parse optional prefix
3355
- const colon = entry.indexOf(":");
3356
- const entryType =
3357
- colon > 0 &&
3358
- ["user", "guild", "channel"].includes(entry.slice(0, colon))
3359
- ? entry.slice(0, colon)
3360
- : null;
3361
- const entryId = colon > 0 ? entry.slice(colon + 1) : entry;
3362
-
3363
- const tag = document.createElement("span");
3364
- tag.style.cssText =
3365
- "display:inline-flex;align-items:center;gap:5px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:999px;padding:2px 10px 2px 8px;font-size:0.81rem;";
3366
-
3367
- if (entryType) {
3368
- const badge = document.createElement("span");
3369
- badge.style.cssText = `background:${TYPE_COLORS[entryType] || "#888"};color:#000;border-radius:999px;padding:1px 7px;font-size:0.71rem;font-weight:600;`;
3370
- badge.textContent = TYPE_LABELS[entryType] || entryType;
3371
- tag.appendChild(badge);
3372
- tag.appendChild(document.createTextNode(" " + entryId));
3373
- } else {
3374
- tag.appendChild(document.createTextNode(entry));
3375
- }
3376
-
3377
- const removeBtn = document.createElement("button");
3378
- removeBtn.style.cssText =
3379
- "background:none;border:none;cursor:pointer;color:var(--text-muted);padding:0;font-size:1rem;line-height:1;margin-left:2px;";
3380
- removeBtn.textContent = "×";
3381
- removeBtn.title = "Remove";
3382
- removeBtn.addEventListener("click", async () => {
3383
- const newList = list.filter((n) => n !== entry);
3384
- try {
3385
- await wlCfg.saveFn(newList);
3386
- list = newList;
3387
- _buildWhitelistPanel(panel, list, wlCfg, platformId);
3388
- const lbl = document.getElementById(`wl-label-${platformId}`);
3389
- if (lbl) lbl.innerHTML = _wlLabel(wlCfg.label, newList.length);
3390
- } catch {
3391
- toast("Failed to remove", "error");
3392
- }
3393
- });
3394
- tag.appendChild(removeBtn);
3395
- tags.appendChild(tag);
3396
- }
3397
- panel.appendChild(tags);
3398
- }
3399
-
3400
- if (wlCfg.allowAdd) {
3401
- const row = document.createElement("div");
3402
- row.style.cssText = "display:flex;gap:8px;align-items:center;";
3403
-
3404
- if (wlCfg.addTypes) {
3405
- // Type selector + ID input for Discord
3406
- const sel = document.createElement("select");
3407
- sel.className = "input";
3408
- sel.style.cssText = "flex:0 0 auto;width:110px;";
3409
- for (const t of wlCfg.addTypes) {
3410
- const opt = document.createElement("option");
3411
- opt.value = t;
3412
- opt.textContent = TYPE_LABELS[t] || t;
3413
- sel.appendChild(opt);
3414
- }
3415
- const inp = document.createElement("input");
3416
- inp.type = "text";
3417
- inp.className = "input";
3418
- inp.style.flex = "1";
3419
- inp.placeholder = "Snowflake ID";
3420
- const addBtn = document.createElement("button");
3421
- addBtn.className = "btn btn-primary btn-sm";
3422
- addBtn.textContent = "Add";
3423
- addBtn.addEventListener("click", async () => {
3424
- const id = inp.value.replace(/[^0-9]/g, "").trim();
3425
- if (!id) return;
3426
- const val = `${sel.value}:${id}`;
3427
- if (list.includes(val)) {
3428
- toast("Already in list", "info");
3429
- return;
3430
- }
3431
- const newList = [...list, val];
3432
- try {
3433
- await wlCfg.saveFn(newList);
3434
- list = newList;
3435
- inp.value = "";
3436
- _buildWhitelistPanel(panel, list, wlCfg, platformId);
3437
- const lbl = document.getElementById(`wl-label-${platformId}`);
3438
- if (lbl) lbl.innerHTML = _wlLabel(wlCfg.label, newList.length);
3439
- } catch {
3440
- toast("Failed to add", "error");
3441
- }
3442
- });
3443
- inp.addEventListener("keydown", (e) => {
3444
- if (e.key === "Enter") addBtn.click();
3445
- });
3446
- row.appendChild(sel);
3447
- row.appendChild(inp);
3448
- row.appendChild(addBtn);
3449
- } else {
3450
- // Plain input for telnyx numbers
3451
- const inp = document.createElement("input");
3452
- inp.type = "text";
3453
- inp.className = "input";
3454
- inp.style.flex = "1";
3455
- inp.placeholder = wlCfg.addPlaceholder || "+12125550100";
3456
- const addBtn = document.createElement("button");
3457
- addBtn.className = "btn btn-primary btn-sm";
3458
- addBtn.textContent = "Add";
3459
- addBtn.addEventListener("click", async () => {
3460
- const val = inp.value.replace(/[^0-9+]/g, "").trim();
3461
- if (!val) return;
3462
- if (list.includes(val)) {
3463
- toast("Already in list", "info");
3464
- return;
3465
- }
3466
- const newList = [...list, val];
3467
- try {
3468
- await wlCfg.saveFn(newList);
3469
- list = newList;
3470
- inp.value = "";
3471
- _buildWhitelistPanel(panel, list, wlCfg, platformId);
3472
- const lbl = document.getElementById(`wl-label-${platformId}`);
3473
- if (lbl) lbl.innerHTML = _wlLabel(wlCfg.label, newList.length);
3474
- } catch {
3475
- toast("Failed to add", "error");
3476
- }
3477
- });
3478
- inp.addEventListener("keydown", (e) => {
3479
- if (e.key === "Enter") addBtn.click();
3480
- });
3481
- row.appendChild(inp);
3482
- row.appendChild(addBtn);
3483
- }
3484
- panel.appendChild(row);
3485
- }
3486
- }
3487
-
3488
- async function loadWhitelistUI() {
3489
- /* replaced — whitelist is now inline in each platform card */
3490
- }
3491
-
3492
- // Platform action delegation
3493
- $("#platformList").addEventListener("click", async (e) => {
3494
- const btn = e.target.closest("[data-action]");
3495
- if (!btn) return;
3496
- const { action, platform, method } = btn.dataset;
3497
-
3498
- if (action === "connectPlatform") {
3499
- if (method === "config") {
3500
- if (platform === "telnyx") openTelnyxConfigModal();
3501
- if (platform === "discord") openDiscordConfigModal();
3502
- if (platform === "telegram") openTelegramConfigModal();
3503
- } else {
3504
- socket.emit("messaging:connect", { platform });
3505
- toast(`Connecting to ${platform}…`, "info");
3506
- }
3507
- } else if (action === "disconnectPlatform") {
3508
- try {
3509
- await api("/messaging/disconnect", {
3510
- method: "POST",
3511
- body: { platform },
3512
- });
3513
- loadMessagingPage();
3514
- toast(`${platform} disconnected`, "success");
3515
- } catch (err) {
3516
- toast(err.message, "error");
3517
- }
3518
- } else if (action === "logoutPlatform") {
3519
- try {
3520
- await api("/messaging/logout", { method: "POST", body: { platform } });
3521
- loadMessagingPage();
3522
- toast(`${platform} logged out`, "success");
3523
- } catch (err) {
3524
- toast(err.message, "error");
3525
- }
3526
- }
3527
- });
3528
-
3529
- $("#cancelQR").addEventListener("click", () => {
3530
- $("#messagingQR").classList.add("hidden");
3531
- });
3532
-
3533
- // ── Telnyx Config Modal ──────────────────────────────────────────────────────
3534
-
3535
- async function openTelnyxConfigModal() {
3536
- // Pre-fill from saved DB config if available
3537
- let saved = {};
3538
- try {
3539
- const st = await api("/messaging/status/telnyx");
3540
- // Config is not exposed in status; try settings instead
3541
- } catch { }
3542
- try {
3543
- const s = await api("/settings");
3544
- if (s.telnyx_config)
3545
- saved =
3546
- typeof s.telnyx_config === "string"
3547
- ? JSON.parse(s.telnyx_config)
3548
- : s.telnyx_config;
3549
- } catch { }
3550
-
3551
- const TTS_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
3552
- const TTS_MODELS = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"];
3553
- const STT_MODELS = ["whisper-1", "gpt-4o-transcribe"];
3554
-
3555
- const overlay = document.createElement("div");
3556
- overlay.style.cssText =
3557
- "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;padding:16px;";
3558
-
3559
- overlay.innerHTML = `
3560
- <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:28px 28px 22px;max-width:480px;width:100%;max-height:90vh;overflow-y:auto;">
3561
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
3562
- <div style="font-size:1.15rem;font-weight:700;">📞 Telnyx Voice — Configuration</div>
3563
- <button id="telnyxModalClose" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--text-muted);">×</button>
3564
- </div>
3565
- <div style="display:flex;flex-direction:column;gap:14px;">
3566
- <div>
3567
- <label class="label" style="display:block;margin-bottom:4px;">Telnyx API Key *</label>
3568
- <input id="telnyx_apiKey" class="input" type="password" placeholder="KEY0..." value="${escapeHtml(saved.apiKey || "")}" autocomplete="off"/>
3569
- </div>
3570
- <div>
3571
- <label class="label" style="display:block;margin-bottom:4px;">Telnyx Phone Number * <span style="color:var(--text-muted);font-size:0.78rem;">(E.164, e.g. +12125550100)</span></label>
3572
- <input id="telnyx_phoneNumber" class="input" type="text" placeholder="+12125550100" value="${escapeHtml(saved.phoneNumber || "")}"/>
3573
- </div>
3574
- <div>
3575
- <label class="label" style="display:block;margin-bottom:4px;">Call Control Application ID (Connection ID) *</label>
3576
- <input id="telnyx_connectionId" class="input" type="text" placeholder="..." value="${escapeHtml(saved.connectionId || "")}"/>
3577
- </div>
3578
- <div>
3579
- <label class="label" style="display:block;margin-bottom:4px;">Webhook Base URL * <span style="color:var(--text-muted);font-size:0.78rem;">(public URL this server is reachable at)</span></label>
3580
- <input id="telnyx_webhookUrl" class="input" type="text" placeholder="https://xyz.ngrok.io" value="${escapeHtml(saved.webhookUrl || "")}"/>
3581
- <div style="font-size:0.76rem;color:var(--text-muted);margin-top:4px;">Set your Telnyx webhook to: <code style="background:var(--bg-secondary);padding:1px 5px;border-radius:4px;">&lt;URL&gt;/api/telnyx/webhook</code></div>
3582
- </div>
3583
- <div style="display:flex;gap:12px;">
3584
- <div style="flex:1;">
3585
- <label class="label" style="display:block;margin-bottom:4px;">TTS Voice</label>
3586
- <select id="telnyx_ttsVoice" class="input" style="width:100%;">
3587
- ${TTS_VOICES.map((v) => `<option value="${v}"${(saved.ttsVoice || "alloy") === v ? " selected" : ""}>${v}</option>`).join("")}
3588
- </select>
3589
- </div>
3590
- <div style="flex:1;">
3591
- <label class="label" style="display:block;margin-bottom:4px;">TTS Model</label>
3592
- <select id="telnyx_ttsModel" class="input" style="width:100%;">
3593
- ${TTS_MODELS.map((m) => `<option value="${m}"${(saved.ttsModel || "tts-1") === m ? " selected" : ""}>${m}</option>`).join("")}
3594
- </select>
3595
- </div>
3596
- </div>
3597
- <div>
3598
- <label class="label" style="display:block;margin-bottom:4px;">STT Model</label>
3599
- <select id="telnyx_sttModel" class="input" style="width:100%;">
3600
- ${STT_MODELS.map((m) => `<option value="${m}"${(saved.sttModel || "whisper-1") === m ? " selected" : ""}>${m}</option>`).join("")}
3601
- </select>
3602
- <div style="font-size:0.76rem;color:var(--text-muted);margin-top:4px;">Uses <code style="background:var(--bg-secondary);padding:1px 5px;border-radius:4px;">OPENAI_API_KEY</code> from environment for TTS + STT.</div>
3603
- </div>
3604
- </div>
3605
- <div style="display:flex;gap:10px;margin-top:22px;justify-content:flex-end;">
3606
- <button id="telnyxModalCancel" class="btn btn-secondary">Cancel</button>
3607
- <button id="telnyxModalSave" class="btn btn-primary">Connect</button>
3608
- </div>
3609
- </div>`;
3610
-
3611
- document.body.appendChild(overlay);
3612
-
3613
- const close = () => overlay.remove();
3614
- overlay.querySelector("#telnyxModalClose").addEventListener("click", close);
3615
- overlay.querySelector("#telnyxModalCancel").addEventListener("click", close);
3616
- overlay.addEventListener("click", (e) => {
3617
- if (e.target === overlay) close();
3618
- });
3619
-
3620
- overlay
3621
- .querySelector("#telnyxModalSave")
3622
- .addEventListener("click", async () => {
3623
- const config = {
3624
- apiKey: overlay.querySelector("#telnyx_apiKey").value.trim(),
3625
- phoneNumber: overlay.querySelector("#telnyx_phoneNumber").value.trim(),
3626
- connectionId: overlay
3627
- .querySelector("#telnyx_connectionId")
3628
- .value.trim(),
3629
- webhookUrl: overlay.querySelector("#telnyx_webhookUrl").value.trim(),
3630
- ttsVoice: overlay.querySelector("#telnyx_ttsVoice").value,
3631
- ttsModel: overlay.querySelector("#telnyx_ttsModel").value,
3632
- sttModel: overlay.querySelector("#telnyx_sttModel").value,
3633
- };
3634
- if (
3635
- !config.apiKey ||
3636
- !config.phoneNumber ||
3637
- !config.connectionId ||
3638
- !config.webhookUrl
3639
- ) {
3640
- toast("Please fill in all required fields", "error");
3641
- return;
3642
- }
3643
- try {
3644
- // Save config snapshot for pre-fill
3645
- await api("/settings", {
3646
- method: "PUT",
3647
- body: { telnyx_config: JSON.stringify(config) },
3648
- });
3649
- await api("/messaging/connect", {
3650
- method: "POST",
3651
- body: { platform: "telnyx", config },
3652
- });
3653
- toast("Telnyx Voice connecting…", "success");
3654
- close();
3655
- setTimeout(loadMessagingPage, 1000);
3656
- } catch (err) {
3657
- toast("Failed to connect: " + (err.message || err), "error");
3658
- }
3659
- });
3660
- }
3661
-
3662
- // ── Discord Config Modal ─────────────────────────────────────────────────────
3663
-
3664
- async function openDiscordConfigModal() {
3665
- let saved = {};
3666
- try {
3667
- const s = await api("/settings");
3668
- if (s.discord_config)
3669
- saved =
3670
- typeof s.discord_config === "string"
3671
- ? JSON.parse(s.discord_config)
3672
- : s.discord_config;
3673
- } catch { }
3674
-
3675
- const overlay = document.createElement("div");
3676
- overlay.style.cssText =
3677
- "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;padding:16px;";
3678
-
3679
- overlay.innerHTML = `
3680
- <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:28px 28px 22px;max-width:460px;width:100%;max-height:90vh;overflow-y:auto;">
3681
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
3682
- <div style="font-size:1.15rem;font-weight:700;">🎮 Discord — Configuration</div>
3683
- <button id="discordModalClose" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--text-muted);">×</button>
3684
- </div>
3685
- <div style="display:flex;flex-direction:column;gap:14px;">
3686
- <div>
3687
- <label class="label" style="display:block;margin-bottom:4px;">Bot Token *</label>
3688
- <input id="discord_token" class="input" type="password" placeholder="MTxxxxxxxx..." value="${escapeHtml(saved.token || "")}" autocomplete="off"/>
3689
- <div style="font-size:0.76rem;color:var(--text-muted);margin-top:4px;">Create a bot at <a href="https://discord.com/developers/applications" target="_blank" style="color:var(--accent);">discord.com/developers</a>. Enable <strong>Message Content</strong> privileged intent.</div>
3690
- </div>
3691
- </div>
3692
- <div style="display:flex;gap:10px;margin-top:22px;justify-content:flex-end;">
3693
- <button id="discordModalCancel" class="btn btn-secondary">Cancel</button>
3694
- <button id="discordModalSave" class="btn btn-primary">Connect</button>
3695
- </div>
3696
- </div>`;
3697
-
3698
- document.body.appendChild(overlay);
3699
-
3700
- const close = () => overlay.remove();
3701
- overlay.querySelector("#discordModalClose").addEventListener("click", close);
3702
- overlay.querySelector("#discordModalCancel").addEventListener("click", close);
3703
- overlay.addEventListener("click", (e) => {
3704
- if (e.target === overlay) close();
3705
- });
3706
-
3707
- overlay
3708
- .querySelector("#discordModalSave")
3709
- .addEventListener("click", async () => {
3710
- const config = {
3711
- token: overlay.querySelector("#discord_token").value.trim(),
3712
- };
3713
- if (!config.token) {
3714
- toast("Bot token is required", "error");
3715
- return;
3716
- }
3717
- try {
3718
- await api("/settings", {
3719
- method: "PUT",
3720
- body: { discord_config: JSON.stringify(config) },
3721
- });
3722
- await api("/messaging/connect", {
3723
- method: "POST",
3724
- body: { platform: "discord", config },
3725
- });
3726
- toast("Discord connecting…", "success");
3727
- close();
3728
- setTimeout(loadMessagingPage, 1500);
3729
- } catch (err) {
3730
- toast("Failed to connect: " + (err.message || err), "error");
3731
- }
3732
- });
3733
- }
3734
-
3735
- // ── Telegram Config Modal ─────────────────────────────────────────────
3736
-
3737
- async function openTelegramConfigModal() {
3738
- let saved = {};
3739
- try {
3740
- const s = await api("/settings");
3741
- if (s.telegram_config)
3742
- saved =
3743
- typeof s.telegram_config === "string"
3744
- ? JSON.parse(s.telegram_config)
3745
- : s.telegram_config;
3746
- } catch { }
3747
-
3748
- const overlay = document.createElement("div");
3749
- overlay.style.cssText =
3750
- "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;padding:16px;";
3751
-
3752
- overlay.innerHTML = `
3753
- <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:28px 28px 22px;max-width:460px;width:100%;max-height:90vh;overflow-y:auto;">
3754
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
3755
- <div style="font-size:1.15rem;font-weight:700;">✈️ Telegram — Configuration</div>
3756
- <button id="telegramModalClose" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--text-muted);">&#xD7;</button>
3757
- </div>
3758
- <div style="display:flex;flex-direction:column;gap:14px;">
3759
- <div>
3760
- <label class="label" style="display:block;margin-bottom:4px;">Bot Token *</label>
3761
- <input id="telegram_token" class="input" type="password" placeholder="123456:ABCdef..." value="${escapeHtml(saved.botToken || "")}" autocomplete="off"/>
3762
- <div style="font-size:0.76rem;color:var(--text-muted);margin-top:4px;">Get a token from <a href="https://t.me/BotFather" target="_blank" style="color:var(--accent);">@BotFather</a> on Telegram. Send the bot a message or add it to a group to start receiving messages.</div>
3763
- </div>
3764
- </div>
3765
- <div style="display:flex;gap:10px;margin-top:22px;justify-content:flex-end;">
3766
- <button id="telegramModalCancel" class="btn btn-secondary">Cancel</button>
3767
- <button id="telegramModalSave" class="btn btn-primary">Connect</button>
3768
- </div>
3769
- </div>`;
3770
-
3771
- document.body.appendChild(overlay);
3772
-
3773
- const close = () => overlay.remove();
3774
- overlay.querySelector("#telegramModalClose").addEventListener("click", close);
3775
- overlay
3776
- .querySelector("#telegramModalCancel")
3777
- .addEventListener("click", close);
3778
- overlay.addEventListener("click", (e) => {
3779
- if (e.target === overlay) close();
3780
- });
3781
-
3782
- overlay
3783
- .querySelector("#telegramModalSave")
3784
- .addEventListener("click", async () => {
3785
- const config = {
3786
- botToken: overlay.querySelector("#telegram_token").value.trim(),
3787
- };
3788
- if (!config.botToken) {
3789
- toast("Bot token is required", "error");
3790
- return;
3791
- }
3792
- try {
3793
- await api("/settings", {
3794
- method: "PUT",
3795
- body: { telegram_config: JSON.stringify(config) },
3796
- });
3797
- await api("/messaging/connect", {
3798
- method: "POST",
3799
- body: { platform: "telegram", config },
3800
- });
3801
- toast("Telegram connecting…", "success");
3802
- close();
3803
- setTimeout(loadMessagingPage, 1500);
3804
- } catch (err) {
3805
- toast("Failed to connect: " + (err.message || err), "error");
3806
- }
3807
- });
3808
- }
3809
-
3810
- socket.on("messaging:qr", (data) => {
3811
- $("#messagingQR").classList.remove("hidden");
3812
- const container = $("#qrContainer");
3813
- container.innerHTML = `<img src="https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.qr)}&size=280x280" alt="QR Code">`;
3814
- });
3815
-
3816
- socket.on("messaging:connected", (data) => {
3817
- $("#messagingQR").classList.add("hidden");
3818
- toast(`${data.platform} connected!`, "success");
3819
- loadMessagingPage();
3820
- });
3821
-
3822
- socket.on("messaging:sent", (data) => {
3823
- appendSocialMessage(data.platform, "assistant", data.content, "me");
3824
- });
3825
-
3826
- socket.on("messaging:disconnected", () => loadMessagingPage());
3827
- socket.on("messaging:logged_out", () => loadMessagingPage());
3828
-
3829
- socket.on("messaging:error", (data) => {
3830
- toast(data && data.error ? data.error : "Messaging error", "error");
3831
- });
3832
-
3833
- socket.on("messaging:blocked_sender", (data) => {
3834
- // Show a persistent banner so the user can see the raw ID and add it to the whitelist
3835
- const platform = data.platform || "whatsapp";
3836
- const rawId = data.sender || data.chatId || "unknown";
3837
- const bannerId = `blocked-banner-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
3838
- if (document.getElementById(bannerId)) return; // don't stack duplicates
3839
-
3840
- const platformLabel =
3841
- platform === "telnyx"
3842
- ? "📞 Blocked call"
3843
- : platform === "discord"
3844
- ? "🎮 Blocked Discord message"
3845
- : platform === "telegram"
3846
- ? "✈️ Blocked Telegram message"
3847
- : "⚠ Blocked message";
3848
-
3849
- const banner = document.createElement("div");
3850
- banner.id = bannerId;
3851
- banner.style.cssText =
3852
- "position:fixed;bottom:80px;right:20px;z-index:9999;max-width:380px;background:var(--bg-card);border:1px solid var(--border);border-left:4px solid #f59e0b;border-radius:10px;padding:14px 16px;box-shadow:0 4px 24px rgba(0,0,0,0.25);font-size:0.86rem;";
3853
- banner.innerHTML = `
3854
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
3855
- <div>
3856
- <div style="font-weight:600;margin-bottom:4px;">${platformLabel}</div>
3857
- <div style="color:var(--text-muted);margin-bottom:10px;">${platform === "telnyx" ? "From" : "Sender"}: <code style="font-size:0.82rem;background:var(--bg-secondary);padding:1px 6px;border-radius:4px;">${escapeHtml(rawId)}</code>${data.senderName ? ` &mdash; ${escapeHtml(data.senderName)}` : ""}${data.meta ? ` <span style="font-size:0.78rem;">(${escapeHtml(data.meta)})</span>` : ""}</div>
3858
- <div style="display:flex;flex-wrap:wrap;gap:8px;" id="wb-btns-${bannerId}">
3859
- ${data.suggestions && data.suggestions.length
3860
- ? data.suggestions
3861
- .map(
3862
- (s, i) =>
3863
- `<button class="btn btn-sm btn-primary" id="wb-sug-${bannerId}-${i}" data-pid="${escapeHtml(s.prefixedId)}">${escapeHtml(s.label)}</button>`,
3864
- )
3865
- .join("")
3866
- : `<button class="btn btn-sm btn-primary" id="wb-add-${bannerId}">Add to whitelist</button>`
3867
- }
3868
- <button class="btn btn-sm btn-secondary" id="wb-dismiss-${bannerId}">Dismiss</button>
3869
- </div>
3870
- </div>
3871
- </div>`;
3872
-
3873
- document.body.appendChild(banner);
3874
-
3875
- document
3876
- .getElementById(`wb-dismiss-${bannerId}`)
3877
- .addEventListener("click", () => banner.remove());
3878
-
3879
- // Helper: add a prefixed/plain ID to a platform whitelist, refresh cards
3880
- async function _wbSave(platform, entryKey) {
3881
- if (platform === "telnyx") {
3882
- const s = await api("/settings");
3883
- let list = [];
3884
- try {
3885
- list = JSON.parse(s.platform_whitelist_telnyx || "[]");
3886
- if (!Array.isArray(list)) list = [];
3887
- } catch {
3888
- list = [];
3889
- }
3890
- if (!list.includes(entryKey)) list.push(entryKey);
3891
- await api("/messaging/telnyx/whitelist", {
3892
- method: "PUT",
3893
- body: { numbers: list },
3894
- });
3895
- } else if (platform === "discord") {
3896
- const s = await api("/settings");
3897
- let list = [];
3898
- try {
3899
- list = JSON.parse(s.platform_whitelist_discord || "[]");
3900
- if (!Array.isArray(list)) list = [];
3901
- } catch {
3902
- list = [];
3903
- }
3904
- const prefixed = entryKey.includes(":") ? entryKey : `user:${entryKey}`;
3905
- if (!list.includes(prefixed)) list.push(prefixed);
3906
- await api("/messaging/discord/whitelist", {
3907
- method: "PUT",
3908
- body: { ids: list },
3909
- });
3910
- } else if (platform === "telegram") {
3911
- const s = await api("/settings");
3912
- let list = [];
3913
- try {
3914
- list = JSON.parse(s.platform_whitelist_telegram || "[]");
3915
- if (!Array.isArray(list)) list = [];
3916
- } catch {
3917
- list = [];
3918
- }
3919
- const prefixed = entryKey.includes(":") ? entryKey : `user:${entryKey}`;
3920
- if (!list.includes(prefixed)) list.push(prefixed);
3921
- await api("/messaging/telegram/whitelist", {
3922
- method: "PUT",
3923
- body: { ids: list },
3924
- });
3925
- } else {
3926
- // whatsapp
3927
- const s = await api("/settings");
3928
- let list = [];
3929
- try {
3930
- list = JSON.parse(s.platform_whitelist_whatsapp || "[]");
3931
- if (!Array.isArray(list)) list = [];
3932
- } catch {
3933
- list = [];
3934
- }
3935
- if (!list.includes(entryKey)) list.push(entryKey);
3936
- await api("/settings", {
3937
- method: "PUT",
3938
- body: { platform_whitelist_whatsapp: JSON.stringify(list) },
3939
- });
3940
- }
3941
- }
3942
-
3943
- // Wire suggestion buttons (Discord) or the single Add button (other platforms)
3944
- if (data.suggestions && data.suggestions.length) {
3945
- data.suggestions.forEach((s, i) => {
3946
- const btn = document.getElementById(`wb-sug-${bannerId}-${i}`);
3947
- if (!btn) return;
3948
- btn.addEventListener("click", async () => {
3949
- try {
3950
- await _wbSave(platform, s.prefixedId);
3951
- toast(`Added ${s.prefixedId} to whitelist`, "success");
3952
- banner.remove();
3953
- if (document.querySelector("#page-messaging.active"))
3954
- loadMessagingPage();
3955
- } catch (err) {
3956
- toast("Failed to save: " + err.message, "error");
3957
- }
3958
- });
3959
- });
3960
- } else {
3961
- const addBtn = document.getElementById(`wb-add-${bannerId}`);
3962
- if (addBtn)
3963
- addBtn.addEventListener("click", async () => {
3964
- const key =
3965
- platform === "whatsapp"
3966
- ? normalizeWhatsAppWhitelistEntry(rawId)
3967
- : rawId.replace(/[^0-9]/g, "") || rawId;
3968
- try {
3969
- await _wbSave(platform, key);
3970
- toast(`Added ${key} to whitelist`, "success");
3971
- banner.remove();
3972
- if (document.querySelector("#page-messaging.active"))
3973
- loadMessagingPage();
3974
- } catch (err) {
3975
- toast("Failed to save: " + err.message, "error");
3976
- }
3977
- });
3978
- }
3979
- });
3980
-
3981
- // ── Browser Page (removed - integrated into flow) ──
3982
-
3983
- // ── Init ──
3984
-
3985
- // model is fixed: grok-4-1-fast-reasoning; nothing to load here
3986
-
3987
- // ── Protocols ──
3988
- let currentProtocolId = null;
3989
-
3990
- async function loadProtocolsPage() {
3991
- try {
3992
- const res = await fetch("/api/protocols");
3993
- if (!res.ok) throw new Error("Failed to load protocols");
3994
- const protocols = await res.json();
3995
- renderProtocolsList(protocols);
3996
- } catch (err) {
3997
- console.error(err);
3998
- }
3999
- }
4000
-
4001
- function renderProtocolsList(protocols) {
4002
- const container = $("#protocolsList");
4003
- if (protocols.length === 0) {
4004
- container.innerHTML =
4005
- '<div class="empty-state">No protocols found. Create one.</div>';
4006
- return;
4007
- }
4008
- container.className = "protocols-list";
4009
- container.innerHTML = protocols
4010
- .map(
4011
- (p) => `
4012
- <div class="item-card">
4013
- <div class="item-card-header">
4014
- <div class="item-card-title">${p.name}</div>
4015
- <div class="item-card-actions">
4016
- <button class="btn btn-sm btn-secondary" onclick="editProtocol(${p.id})">Edit</button>
4017
- <button class="btn btn-sm btn-danger" onclick="deleteProtocol(${p.id})">&times;</button>
4018
- </div>
4019
- </div>
4020
- <div class="item-card-meta">${p.description || "No description"}</div>
4021
- </div>
4022
- `,
4023
- )
4024
- .join("");
4025
- }
4026
-
4027
- $("#closeProtocolModal")?.addEventListener("click", () =>
4028
- $("#protocolModal")?.classList.add("hidden"),
4029
- );
4030
- $("#cancelProtocolModal")?.addEventListener("click", () =>
4031
- $("#protocolModal")?.classList.add("hidden"),
4032
- );
4033
-
4034
- $("#addProtocolBtn")?.addEventListener("click", () => {
4035
- currentProtocolId = null;
4036
- $("#protocolModalTitle").textContent = "Add Protocol";
4037
- $("#protocolName").value = "";
4038
- $("#protocolDesc").value = "";
4039
- $("#protocolContent").value = "";
4040
- $("#protocolModal")?.classList.remove("hidden");
4041
- });
4042
-
4043
- $("#saveProtocolBtn").addEventListener("click", async () => {
4044
- const name = $("#protocolName").value.trim();
4045
- const description = $("#protocolDesc").value.trim();
4046
- const content = $("#protocolContent").value.trim();
4047
-
4048
- if (!name || !content) {
4049
- alert("Name and Content are required");
4050
- return;
4051
- }
4052
-
4053
- const payload = { name, description, content };
4054
- const method = currentProtocolId ? "PUT" : "POST";
4055
- const url = currentProtocolId
4056
- ? `/api/protocols/${currentProtocolId}`
4057
- : "/api/protocols";
4058
-
4059
- try {
4060
- const res = await fetch(url, {
4061
- method,
4062
- headers: { "Content-Type": "application/json" },
4063
- body: JSON.stringify(payload),
4064
- });
4065
- if (!res.ok) {
4066
- const err = await res.json();
4067
- throw new Error(err.error || "Failed to save: " + res.status);
4068
- }
4069
- $("#protocolModal")?.classList.add("hidden");
4070
- loadProtocolsPage();
4071
- } catch (err) {
4072
- alert(err.message);
4073
- }
4074
- });
4075
-
4076
- async function editProtocol(id) {
4077
- try {
4078
- const res = await fetch(`/api/protocols/${id}`);
4079
- if (!res.ok) throw new Error("Failed to load protocol");
4080
- const p = await res.json();
4081
-
4082
- currentProtocolId = p.id;
4083
- $("#protocolModalTitle").textContent = "Edit Protocol";
4084
- $("#protocolName").value = p.name;
4085
- $("#protocolDesc").value = p.description || "";
4086
- $("#protocolContent").value = p.content;
4087
- $("#protocolModal")?.classList.remove("hidden");
4088
- } catch (err) {
4089
- alert(err.message);
4090
- }
4091
- }
4092
-
4093
- async function deleteProtocol(id) {
4094
- if (!confirm("Are you sure you want to delete this protocol?")) return;
4095
- try {
4096
- const res = await fetch(`/api/protocols/${id}`, { method: "DELETE" });
4097
- if (!res.ok) throw new Error("Failed to delete protocol");
4098
- loadProtocolsPage();
4099
- } catch (err) {
4100
- alert(err.message);
4101
- }
4102
- }
4103
-
4104
- window.editProtocol = editProtocol;
4105
- window.deleteProtocol = deleteProtocol;