neoagent 1.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.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/neoagent.js +8 -0
- package/com.neoagent.plist +45 -0
- package/docs/configuration.md +45 -0
- package/docs/skills.md +45 -0
- package/lib/manager.js +459 -0
- package/package.json +61 -0
- package/server/db/database.js +239 -0
- package/server/index.js +442 -0
- package/server/middleware/auth.js +35 -0
- package/server/public/app.html +559 -0
- package/server/public/css/app.css +608 -0
- package/server/public/css/styles.css +472 -0
- package/server/public/favicon.svg +17 -0
- package/server/public/js/app.js +3283 -0
- package/server/public/login.html +313 -0
- package/server/routes/agents.js +125 -0
- package/server/routes/auth.js +105 -0
- package/server/routes/browser.js +116 -0
- package/server/routes/mcp.js +164 -0
- package/server/routes/memory.js +193 -0
- package/server/routes/messaging.js +153 -0
- package/server/routes/protocols.js +87 -0
- package/server/routes/scheduler.js +63 -0
- package/server/routes/settings.js +98 -0
- package/server/routes/skills.js +107 -0
- package/server/routes/store.js +1192 -0
- package/server/services/ai/compaction.js +82 -0
- package/server/services/ai/engine.js +1690 -0
- package/server/services/ai/models.js +46 -0
- package/server/services/ai/multiStep.js +112 -0
- package/server/services/ai/providers/anthropic.js +181 -0
- package/server/services/ai/providers/base.js +40 -0
- package/server/services/ai/providers/google.js +187 -0
- package/server/services/ai/providers/grok.js +121 -0
- package/server/services/ai/providers/ollama.js +162 -0
- package/server/services/ai/providers/openai.js +167 -0
- package/server/services/ai/toolRunner.js +218 -0
- package/server/services/browser/controller.js +320 -0
- package/server/services/cli/executor.js +204 -0
- package/server/services/mcp/client.js +260 -0
- package/server/services/memory/embeddings.js +126 -0
- package/server/services/memory/manager.js +431 -0
- package/server/services/messaging/base.js +23 -0
- package/server/services/messaging/discord.js +238 -0
- package/server/services/messaging/manager.js +328 -0
- package/server/services/messaging/telegram.js +243 -0
- package/server/services/messaging/telnyx.js +693 -0
- package/server/services/messaging/whatsapp.js +304 -0
- package/server/services/scheduler/cron.js +312 -0
- package/server/services/websocket.js +191 -0
- package/server/utils/security.js +71 -0
|
@@ -0,0 +1,3283 @@
|
|
|
1
|
+
// ── NeoAgent App ──
|
|
2
|
+
|
|
3
|
+
// Init mermaid if available
|
|
4
|
+
if (window.mermaid) {
|
|
5
|
+
mermaid.initialize({ startOnLoad: false, theme: "dark" });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Global utility to re-run mermaid
|
|
9
|
+
function renderMermaids() {
|
|
10
|
+
if (window.mermaid) {
|
|
11
|
+
try {
|
|
12
|
+
mermaid.init(undefined, $$(".mermaid"));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
console.error("Mermaid render error", e);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const socket = io();
|
|
20
|
+
let isStreaming = false;
|
|
21
|
+
const backgroundRunIds = new Set(); // tracks scheduler/heartbeat run IDs
|
|
22
|
+
|
|
23
|
+
// ── Utility ──
|
|
24
|
+
|
|
25
|
+
function $(sel) {
|
|
26
|
+
return document.querySelector(sel);
|
|
27
|
+
}
|
|
28
|
+
function $$(sel) {
|
|
29
|
+
return document.querySelectorAll(sel);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toast(message, type = "info") {
|
|
33
|
+
const container = $("#toasts");
|
|
34
|
+
const el = document.createElement("div");
|
|
35
|
+
el.className = `toast toast-${type}`;
|
|
36
|
+
el.textContent = message;
|
|
37
|
+
container.appendChild(el);
|
|
38
|
+
setTimeout(() => el.remove(), 4000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function api(path, opts = {}) {
|
|
42
|
+
const res = await fetch(`/api${path}`, {
|
|
43
|
+
headers: { "Content-Type": "application/json", ...opts.headers },
|
|
44
|
+
...opts,
|
|
45
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
46
|
+
});
|
|
47
|
+
const data = await res.json();
|
|
48
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function escapeHtml(str) {
|
|
53
|
+
const div = document.createElement("div");
|
|
54
|
+
div.textContent = str;
|
|
55
|
+
return div.innerHTML;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatTime(ts) {
|
|
59
|
+
return new Date(ts).toLocaleTimeString([], {
|
|
60
|
+
hour: "2-digit",
|
|
61
|
+
minute: "2-digit",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Navigation ──
|
|
66
|
+
|
|
67
|
+
function navigateTo(page) {
|
|
68
|
+
$$(".page").forEach((p) => p.classList.remove("active"));
|
|
69
|
+
$$(".sidebar-btn").forEach((b) => b.classList.remove("active"));
|
|
70
|
+
|
|
71
|
+
const pageEl = $(`#page-${page}`);
|
|
72
|
+
if (pageEl) {
|
|
73
|
+
pageEl.classList.add("active");
|
|
74
|
+
const btn = $(`.sidebar-btn[data-page="${page}"]`);
|
|
75
|
+
if (btn) btn.classList.add("active");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (page === "memory") loadMemoryPage();
|
|
79
|
+
if (page === "skills") loadSkillsPage();
|
|
80
|
+
if (page === "mcp") loadMCPPage();
|
|
81
|
+
if (page === "scheduler") loadSchedulerPage();
|
|
82
|
+
if (page === "messaging") loadMessagingPage();
|
|
83
|
+
if (page === "protocols") loadProtocolsPage();
|
|
84
|
+
if (page === "activity") {
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
ensureTimeline();
|
|
87
|
+
loadActivityHistory();
|
|
88
|
+
if (activityTimeline && activityTimeline.stepCount === 0) {
|
|
89
|
+
api("/agents?limit=1").then(data => {
|
|
90
|
+
if (data.runs && data.runs.length > 0) {
|
|
91
|
+
loadRunOnCanvas(data.runs[0].id, data.runs[0].title, data.runs[0].status);
|
|
92
|
+
}
|
|
93
|
+
}).catch(console.error);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (page === "logs") loadLogsPage();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$$(".sidebar-btn[data-page]").forEach((btn) => {
|
|
101
|
+
btn.addEventListener("click", () => navigateTo(btn.dataset.page));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── Chat ──
|
|
105
|
+
|
|
106
|
+
const chatInput = $("#chatInput");
|
|
107
|
+
const chatMessages = $("#chatMessages");
|
|
108
|
+
const chatEmpty = $("#chatEmpty");
|
|
109
|
+
const sendBtn = $("#chatSendBtn");
|
|
110
|
+
|
|
111
|
+
chatInput.addEventListener("input", () => {
|
|
112
|
+
chatInput.style.height = "auto";
|
|
113
|
+
chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + "px";
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
chatInput.addEventListener("keydown", (e) => {
|
|
117
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
sendMessage();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
sendBtn.addEventListener("click", sendMessage);
|
|
124
|
+
|
|
125
|
+
function sendMessage() {
|
|
126
|
+
const text = chatInput.value.trim();
|
|
127
|
+
if (!text || isStreaming) return;
|
|
128
|
+
|
|
129
|
+
chatEmpty.classList.add("hidden");
|
|
130
|
+
appendMessage("user", text);
|
|
131
|
+
chatInput.value = "";
|
|
132
|
+
chatInput.style.height = "auto";
|
|
133
|
+
|
|
134
|
+
isStreaming = true;
|
|
135
|
+
sendBtn.disabled = true;
|
|
136
|
+
|
|
137
|
+
const thinkingEl = document.createElement("div");
|
|
138
|
+
thinkingEl.className = "chat-thinking";
|
|
139
|
+
thinkingEl.id = "thinking";
|
|
140
|
+
thinkingEl.innerHTML =
|
|
141
|
+
'<div class="spinner"></div><span id="thinkingText">NeoAgent is thinking...</span>';
|
|
142
|
+
chatMessages.appendChild(thinkingEl);
|
|
143
|
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
144
|
+
|
|
145
|
+
// Reset activity for new run
|
|
146
|
+
clearActivity();
|
|
147
|
+
|
|
148
|
+
socket.emit("agent:run", { task: text });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function appendMessage(role, content) {
|
|
152
|
+
const chunks = role === "assistant" ? content.split(/\n\n+/).filter(c => c.trim()) : [content];
|
|
153
|
+
let firstBubble = null;
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
const div = document.createElement("div");
|
|
156
|
+
div.className = `chat-message ${role}`;
|
|
157
|
+
|
|
158
|
+
const avatar = document.createElement("div");
|
|
159
|
+
avatar.className = "chat-avatar";
|
|
160
|
+
avatar.textContent = role === "user" ? "U" : "N";
|
|
161
|
+
|
|
162
|
+
const bubble = document.createElement("div");
|
|
163
|
+
bubble.className = "chat-bubble md-content";
|
|
164
|
+
bubble.innerHTML = renderMarkdown(chunk);
|
|
165
|
+
requestAnimationFrame(renderMermaids);
|
|
166
|
+
|
|
167
|
+
div.appendChild(avatar);
|
|
168
|
+
div.appendChild(bubble);
|
|
169
|
+
chatMessages.appendChild(div);
|
|
170
|
+
if (!firstBubble) firstBubble = bubble;
|
|
171
|
+
}
|
|
172
|
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
173
|
+
return firstBubble;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function appendToolCall(name, args, result) {
|
|
177
|
+
const div = document.createElement("div");
|
|
178
|
+
div.className = "chat-message assistant";
|
|
179
|
+
const avatar = document.createElement("div");
|
|
180
|
+
avatar.className = "chat-avatar";
|
|
181
|
+
avatar.textContent = "N";
|
|
182
|
+
const bubble = document.createElement("div");
|
|
183
|
+
bubble.className = "chat-bubble";
|
|
184
|
+
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>`;
|
|
185
|
+
div.appendChild(avatar);
|
|
186
|
+
div.appendChild(bubble);
|
|
187
|
+
chatMessages.appendChild(div);
|
|
188
|
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Status update (interim message from AI mid-task)
|
|
192
|
+
function appendInterimMessage(message) {
|
|
193
|
+
const div = document.createElement("div");
|
|
194
|
+
div.className = "chat-interim";
|
|
195
|
+
div.innerHTML = `<div class="chat-interim-dot"></div><div class="chat-interim-text">${escapeHtml(message)}</div>`;
|
|
196
|
+
chatMessages.appendChild(div);
|
|
197
|
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Show a social platform message (WhatsApp etc.) in chat
|
|
201
|
+
function appendSocialMessage(platform, role, content, senderName) {
|
|
202
|
+
const div = document.createElement("div");
|
|
203
|
+
div.className =
|
|
204
|
+
role === "user" ? "chat-message social" : "chat-message assistant";
|
|
205
|
+
const avatar = document.createElement("div");
|
|
206
|
+
avatar.className = "chat-avatar";
|
|
207
|
+
avatar.textContent =
|
|
208
|
+
platform === "whatsapp" ? "💬" : platform[0].toUpperCase();
|
|
209
|
+
if (role === "user")
|
|
210
|
+
avatar.style.cssText = "background:#25d36620;color:#25d366;font-size:12px;";
|
|
211
|
+
const bubble = document.createElement("div");
|
|
212
|
+
bubble.className = "chat-bubble";
|
|
213
|
+
const badge = `<div class="chat-platform-badge ${platform.toLowerCase()}">${platform}</div>`;
|
|
214
|
+
const sender =
|
|
215
|
+
role === "user" && senderName
|
|
216
|
+
? `<div class="chat-sender">${escapeHtml(senderName)}</div>`
|
|
217
|
+
: "";
|
|
218
|
+
bubble.innerHTML = badge + sender + renderMarkdown(content);
|
|
219
|
+
requestAnimationFrame(renderMermaids);
|
|
220
|
+
div.appendChild(avatar);
|
|
221
|
+
div.appendChild(bubble);
|
|
222
|
+
chatMessages.appendChild(div);
|
|
223
|
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Load and render chat history from DB
|
|
227
|
+
async function loadChatHistory() {
|
|
228
|
+
try {
|
|
229
|
+
const data = await api("/agents/chat-history?limit=80");
|
|
230
|
+
if (!data.messages || data.messages.length === 0) return;
|
|
231
|
+
chatEmpty.classList.add("hidden");
|
|
232
|
+
for (const msg of data.messages) {
|
|
233
|
+
if (!msg.content) continue;
|
|
234
|
+
if (msg.platform === "web") {
|
|
235
|
+
appendMessage(msg.role, msg.content);
|
|
236
|
+
} else {
|
|
237
|
+
appendSocialMessage(
|
|
238
|
+
msg.platform,
|
|
239
|
+
msg.role,
|
|
240
|
+
msg.content,
|
|
241
|
+
msg.sender_name,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
/* silently skip */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Load history on startup
|
|
251
|
+
loadChatHistory();
|
|
252
|
+
|
|
253
|
+
// Simple markdown renderer
|
|
254
|
+
function renderMarkdown(text) {
|
|
255
|
+
if (!text) return "";
|
|
256
|
+
let html = escapeHtml(text);
|
|
257
|
+
|
|
258
|
+
// Mermaid blocks
|
|
259
|
+
html = html.replace(/```mermaid\n([\s\S]*?)```/g, (match, code) => {
|
|
260
|
+
return `<div class="mermaid-container" style="background:#0f172a;padding:12px;border-radius:8px;margin:8px 0;"><pre class="mermaid">${code}</pre></div>`;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Code blocks
|
|
264
|
+
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>");
|
|
265
|
+
// Inline code
|
|
266
|
+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
267
|
+
// Bold
|
|
268
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
269
|
+
// Italic
|
|
270
|
+
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
271
|
+
// Headers
|
|
272
|
+
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
|
273
|
+
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
|
274
|
+
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
|
275
|
+
|
|
276
|
+
// Tables
|
|
277
|
+
html = html.replace(
|
|
278
|
+
/\n\|?(.+)\|?\n\|?([-:| ]+)\|?\n((?:\|?.*\|?\n?)*)/g,
|
|
279
|
+
(match, header, sub, body) => {
|
|
280
|
+
if (!sub.includes("-")) return match;
|
|
281
|
+
const thead =
|
|
282
|
+
"<thead><tr>" +
|
|
283
|
+
header
|
|
284
|
+
.split("|")
|
|
285
|
+
.filter((c) => c.trim())
|
|
286
|
+
.map((c) => `<th>${c.trim()}</th>`)
|
|
287
|
+
.join("") +
|
|
288
|
+
"</tr></thead>";
|
|
289
|
+
const tbody =
|
|
290
|
+
"<tbody>" +
|
|
291
|
+
body
|
|
292
|
+
.trim()
|
|
293
|
+
.split("\n")
|
|
294
|
+
.map((row) => {
|
|
295
|
+
const parts = row.split("|").filter((c) => c.trim() || c === "");
|
|
296
|
+
if (parts.length === 0) return "";
|
|
297
|
+
return (
|
|
298
|
+
"<tr>" +
|
|
299
|
+
parts.map((c) => `<td>${c.trim()}</td>`).join("") +
|
|
300
|
+
"</tr>"
|
|
301
|
+
);
|
|
302
|
+
})
|
|
303
|
+
.join("") +
|
|
304
|
+
"</tbody>";
|
|
305
|
+
return `\n<div class="table-responsive"><table class="md-table">${thead}${tbody}</table></div>\n`;
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// Lists
|
|
310
|
+
html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
|
|
311
|
+
html = html.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>");
|
|
312
|
+
// Links
|
|
313
|
+
html = html.replace(
|
|
314
|
+
/\[(.+?)\]\((.+?)\)/g,
|
|
315
|
+
'<a href="$2" target="_blank">$1</a>',
|
|
316
|
+
);
|
|
317
|
+
// Line breaks
|
|
318
|
+
html = html.replace(
|
|
319
|
+
/\n(?!(?:<\/th>|<\/tr>|<\/td>|<\/thead>|<\/tbody>|<\/table>|<\/div>|<div|<table|<thead|<tbody|<tr|<th|<td|<pre|<\/pre>|<ul|<\/ul>|<li|<\/li>))/g,
|
|
320
|
+
"<br>",
|
|
321
|
+
);
|
|
322
|
+
return html;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Activity Helpers ──
|
|
326
|
+
|
|
327
|
+
const TOOL_META = {
|
|
328
|
+
execute_command: { icon: "⚡", label: "Terminal", color: "cli" },
|
|
329
|
+
browser_navigate: { icon: "🌐", label: "Browse", color: "browser" },
|
|
330
|
+
browser_click: { icon: "🖱️", label: "Click", color: "browser" },
|
|
331
|
+
browser_type: { icon: "⌨️", label: "Type", color: "browser" },
|
|
332
|
+
browser_extract: { icon: "📋", label: "Extract", color: "browser" },
|
|
333
|
+
browser_screenshot: { icon: "📷", label: "Screenshot", color: "browser" },
|
|
334
|
+
browser_evaluate: { icon: "⚙️", label: "Script", color: "browser" },
|
|
335
|
+
memory_write: { icon: "🧠", label: "Memory Write", color: "memory" },
|
|
336
|
+
memory_read: { icon: "🧠", label: "Memory Read", color: "memory" },
|
|
337
|
+
memory_save: { icon: "🧠", label: "Save Memory", color: "memory" },
|
|
338
|
+
memory_recall: { icon: "🔍", label: "Recall Memory", color: "memory" },
|
|
339
|
+
memory_update_core: { icon: "📌", label: "Core Memory", color: "memory" },
|
|
340
|
+
think: { icon: "💭", label: "Thinking", color: "thinking" },
|
|
341
|
+
send_message: { icon: "💬", label: "Message", color: "messaging" },
|
|
342
|
+
make_call: { icon: "📞", label: "Call", color: "messaging" },
|
|
343
|
+
http_request: { icon: "🔗", label: "HTTP Request", color: "http" },
|
|
344
|
+
read_file: { icon: "📄", label: "Read File", color: "file" },
|
|
345
|
+
write_file: { icon: "📝", label: "Write File", color: "file" },
|
|
346
|
+
list_directory: { icon: "📁", label: "List Dir", color: "file" },
|
|
347
|
+
spawn_subagent: { icon: "🤖", label: "Sub-Agent", color: "agent" },
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
function getToolMeta(name) {
|
|
351
|
+
return TOOL_META[name] || { icon: "🔧", label: name, color: "tool" };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function describeArgs(toolName, args) {
|
|
355
|
+
if (!args) return null;
|
|
356
|
+
switch (toolName) {
|
|
357
|
+
case "execute_command":
|
|
358
|
+
return {
|
|
359
|
+
headline: args.command,
|
|
360
|
+
detail: args.cwd ? `Dir: ${args.cwd}` : null,
|
|
361
|
+
};
|
|
362
|
+
case "browser_navigate":
|
|
363
|
+
return { headline: args.url };
|
|
364
|
+
case "browser_click":
|
|
365
|
+
return {
|
|
366
|
+
headline: args.text ? `"${args.text}"` : args.selector || "element",
|
|
367
|
+
};
|
|
368
|
+
case "browser_type":
|
|
369
|
+
return { headline: `"${args.text}"`, detail: `into ${args.selector}` };
|
|
370
|
+
case "browser_screenshot":
|
|
371
|
+
return {
|
|
372
|
+
headline: args.selector ? `Element: ${args.selector}` : "Full page",
|
|
373
|
+
};
|
|
374
|
+
case "browser_extract":
|
|
375
|
+
return { headline: args.selector || "Page content" };
|
|
376
|
+
case "browser_evaluate":
|
|
377
|
+
return { headline: args.script?.slice(0, 120) };
|
|
378
|
+
case "memory_write":
|
|
379
|
+
return {
|
|
380
|
+
headline: `→ ${args.target}`,
|
|
381
|
+
detail: args.content?.slice(0, 160),
|
|
382
|
+
};
|
|
383
|
+
case "memory_read":
|
|
384
|
+
return {
|
|
385
|
+
headline: `← ${args.target}`,
|
|
386
|
+
detail: args.search ? `Search: "${args.search}"` : null,
|
|
387
|
+
};
|
|
388
|
+
case "memory_save":
|
|
389
|
+
return {
|
|
390
|
+
headline: args.content?.slice(0, 200),
|
|
391
|
+
detail: `${args.category || "episodic"} · importance ${args.importance || 5}`,
|
|
392
|
+
};
|
|
393
|
+
case "memory_recall":
|
|
394
|
+
return {
|
|
395
|
+
headline: `"${args.query}"`,
|
|
396
|
+
detail: args.limit ? `top ${args.limit}` : null,
|
|
397
|
+
};
|
|
398
|
+
case "memory_update_core":
|
|
399
|
+
return {
|
|
400
|
+
headline: `${args.key} → ${String(args.value || "").slice(0, 100)}`,
|
|
401
|
+
};
|
|
402
|
+
case "think":
|
|
403
|
+
return { headline: args.thought?.slice(0, 400) };
|
|
404
|
+
case "http_request":
|
|
405
|
+
return { headline: `${args.method || "GET"} ${args.url}` };
|
|
406
|
+
case "send_message":
|
|
407
|
+
return {
|
|
408
|
+
headline: args.content?.slice(0, 160),
|
|
409
|
+
detail: `${args.platform} → ${args.to}`,
|
|
410
|
+
};
|
|
411
|
+
case "make_call":
|
|
412
|
+
return {
|
|
413
|
+
headline: `Calling ${args.to}`,
|
|
414
|
+
detail: args.greeting?.slice(0, 100),
|
|
415
|
+
};
|
|
416
|
+
case "read_file":
|
|
417
|
+
return { headline: args.path };
|
|
418
|
+
case "write_file":
|
|
419
|
+
return {
|
|
420
|
+
headline: args.path,
|
|
421
|
+
detail: `${(args.content || "").length} chars`,
|
|
422
|
+
};
|
|
423
|
+
case "list_directory":
|
|
424
|
+
return { headline: args.path };
|
|
425
|
+
case "spawn_subagent":
|
|
426
|
+
return { headline: args.task?.slice(0, 200) };
|
|
427
|
+
default: {
|
|
428
|
+
const first = Object.values(args).find((v) => typeof v === "string");
|
|
429
|
+
return first ? { headline: first.slice(0, 160) } : null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function describeResult(toolName, result) {
|
|
435
|
+
if (!result) return null;
|
|
436
|
+
if (result.error) return { type: "error", text: result.error };
|
|
437
|
+
switch (toolName) {
|
|
438
|
+
case "execute_command": {
|
|
439
|
+
const out = (
|
|
440
|
+
result.stdout ||
|
|
441
|
+
result.output ||
|
|
442
|
+
result.stderr ||
|
|
443
|
+
""
|
|
444
|
+
).trim();
|
|
445
|
+
const code = result.exitCode ?? result.exit_code;
|
|
446
|
+
return {
|
|
447
|
+
type: code === 0 || code == null ? "code" : "error",
|
|
448
|
+
text: out.slice(0, 600) || "(no output)",
|
|
449
|
+
meta: code != null ? `Exit ${code}` : null,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
case "browser_navigate":
|
|
453
|
+
case "browser_click":
|
|
454
|
+
case "browser_type":
|
|
455
|
+
case "browser_screenshot":
|
|
456
|
+
case "browser_evaluate":
|
|
457
|
+
return { type: "screenshot", meta: result.title || null };
|
|
458
|
+
case "memory_write":
|
|
459
|
+
return { type: "success", text: "Saved ✓" };
|
|
460
|
+
case "memory_read": {
|
|
461
|
+
const txt =
|
|
462
|
+
typeof result === "string"
|
|
463
|
+
? result
|
|
464
|
+
: result.content || JSON.stringify(result);
|
|
465
|
+
return { type: "output", text: txt.slice(0, 400) };
|
|
466
|
+
}
|
|
467
|
+
case "memory_save":
|
|
468
|
+
return { type: "success", text: "Saved to memory ✓" };
|
|
469
|
+
case "memory_update_core":
|
|
470
|
+
return { type: "success", text: "Core memory updated ✓" };
|
|
471
|
+
case "memory_recall": {
|
|
472
|
+
const results = result?.results || [];
|
|
473
|
+
if (!results.length) return { type: "output", text: "Nothing found" };
|
|
474
|
+
const preview = results
|
|
475
|
+
.slice(0, 3)
|
|
476
|
+
.map((r) => `• ${r.content}`)
|
|
477
|
+
.join("\n");
|
|
478
|
+
return { type: "output", text: preview };
|
|
479
|
+
}
|
|
480
|
+
case "think":
|
|
481
|
+
return null;
|
|
482
|
+
case "http_request": {
|
|
483
|
+
const s = result.status;
|
|
484
|
+
const cls = s >= 200 && s < 300 ? "ok" : s >= 400 ? "err" : "warn";
|
|
485
|
+
return {
|
|
486
|
+
type: "output",
|
|
487
|
+
text: (result.body || "").slice(0, 400),
|
|
488
|
+
meta: `HTTP ${s}`,
|
|
489
|
+
statusClass: cls,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
case "read_file":
|
|
493
|
+
return { type: "code", text: (result.content || "").slice(0, 400) };
|
|
494
|
+
case "write_file":
|
|
495
|
+
return { type: "success", text: "File written ✓" };
|
|
496
|
+
case "list_directory": {
|
|
497
|
+
const items = (result.entries || [])
|
|
498
|
+
.slice(0, 20)
|
|
499
|
+
.map((e) => e.name || e)
|
|
500
|
+
.join("\n");
|
|
501
|
+
return { type: "code", text: items };
|
|
502
|
+
}
|
|
503
|
+
default: {
|
|
504
|
+
const txt =
|
|
505
|
+
typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
506
|
+
return { type: "output", text: txt.slice(0, 400) };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── Activity Timeline ──
|
|
512
|
+
|
|
513
|
+
class ActivityTimeline {
|
|
514
|
+
constructor(feedEl) {
|
|
515
|
+
this.feed = feedEl;
|
|
516
|
+
this.steps = new Map(); // stepId → { el, cardEl }
|
|
517
|
+
this.stepCount = 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
addNode(stepId, toolName, toolArgs) {
|
|
521
|
+
if (this.steps.has(stepId)) return this.steps.get(stepId).el;
|
|
522
|
+
this._clearEmpty();
|
|
523
|
+
const meta = getToolMeta(toolName);
|
|
524
|
+
const desc = describeArgs(toolName, toolArgs);
|
|
525
|
+
|
|
526
|
+
const stepEl = document.createElement("div");
|
|
527
|
+
stepEl.className = `atl-step running`;
|
|
528
|
+
stepEl.dataset.color = meta.color;
|
|
529
|
+
stepEl.id = `atl-step-${stepId}`;
|
|
530
|
+
|
|
531
|
+
const summaryText = desc?.headline
|
|
532
|
+
? escapeHtml(desc.headline.slice(0, 120))
|
|
533
|
+
: escapeHtml(meta.label);
|
|
534
|
+
|
|
535
|
+
let urlChip = "";
|
|
536
|
+
if (toolName === "browser_navigate" && toolArgs?.url) {
|
|
537
|
+
const u = toolArgs.url;
|
|
538
|
+
urlChip = `<a class="atl-url-chip" href="${escapeHtml(u)}" target="_blank" rel="noopener noreferrer">${escapeHtml(u.length > 80 ? u.slice(0, 80) + "…" : u)}</a>`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
stepEl.innerHTML = `
|
|
542
|
+
<div class="atl-spine">
|
|
543
|
+
<div class="atl-dot">${meta.icon}</div>
|
|
544
|
+
<div class="atl-connector"></div>
|
|
545
|
+
</div>
|
|
546
|
+
<div class="atl-card open" id="atl-card-${stepId}">
|
|
547
|
+
<div class="atl-card-head" data-step="${stepId}">
|
|
548
|
+
<span class="atl-card-label">${escapeHtml(meta.label)}</span>
|
|
549
|
+
<span class="atl-card-summary">${summaryText}</span>
|
|
550
|
+
<span class="atl-status-chip running" id="atl-chip-${stepId}">running</span>
|
|
551
|
+
<span class="atl-toggle">▾</span>
|
|
552
|
+
</div>
|
|
553
|
+
<div class="atl-card-body">
|
|
554
|
+
${desc?.headline ? `<div class="atl-cmd">${escapeHtml(desc.headline)}</div>` : ""}
|
|
555
|
+
${desc?.detail ? `<div class="atl-detail">${escapeHtml(desc.detail)}</div>` : ""}
|
|
556
|
+
${urlChip}
|
|
557
|
+
<div id="atl-result-${stepId}"></div>
|
|
558
|
+
</div>
|
|
559
|
+
</div>`;
|
|
560
|
+
|
|
561
|
+
this.feed.appendChild(stepEl);
|
|
562
|
+
this.steps.set(stepId, {
|
|
563
|
+
el: stepEl,
|
|
564
|
+
cardEl: stepEl.querySelector(`#atl-card-${stepId}`),
|
|
565
|
+
isResponse: false,
|
|
566
|
+
});
|
|
567
|
+
this.stepCount++;
|
|
568
|
+
|
|
569
|
+
// Toggle open/close on header click
|
|
570
|
+
stepEl.querySelector(".atl-card-head").addEventListener("click", () => {
|
|
571
|
+
stepEl.querySelector(`#atl-card-${stepId}`).classList.toggle("open");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
stepEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
575
|
+
return stepEl;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
updateNode(stepId, toolName, result, screenshotPath, status) {
|
|
579
|
+
const info = this.steps.get(stepId);
|
|
580
|
+
if (!info) return;
|
|
581
|
+
|
|
582
|
+
const chip = document.getElementById(`atl-chip-${stepId}`);
|
|
583
|
+
if (chip) {
|
|
584
|
+
chip.className = `atl-status-chip ${status}`;
|
|
585
|
+
if (status === "completed") {
|
|
586
|
+
chip.textContent = "done";
|
|
587
|
+
} else if (status === "failed") {
|
|
588
|
+
chip.textContent = "failed";
|
|
589
|
+
} else {
|
|
590
|
+
chip.textContent = status; // Keep "running" or "pending"
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (status === "completed" || status === "failed") {
|
|
595
|
+
info.el.classList.remove("running");
|
|
596
|
+
}
|
|
597
|
+
if (status === "failed") info.el.dataset.color = "tool";
|
|
598
|
+
|
|
599
|
+
const resultEl = document.getElementById(`atl-result-${stepId}`);
|
|
600
|
+
if (!resultEl) return;
|
|
601
|
+
|
|
602
|
+
// Screenshot
|
|
603
|
+
if (screenshotPath) {
|
|
604
|
+
const wrap = document.createElement("div");
|
|
605
|
+
wrap.className = "atl-screenshot-wrap";
|
|
606
|
+
const a = document.createElement("a");
|
|
607
|
+
a.href = screenshotPath;
|
|
608
|
+
a.target = "_blank";
|
|
609
|
+
a.rel = "noopener noreferrer";
|
|
610
|
+
const img = document.createElement("img");
|
|
611
|
+
img.className = "atl-screenshot";
|
|
612
|
+
img.src = screenshotPath;
|
|
613
|
+
img.alt = "";
|
|
614
|
+
img.loading = "lazy";
|
|
615
|
+
a.appendChild(img);
|
|
616
|
+
wrap.appendChild(a);
|
|
617
|
+
resultEl.appendChild(wrap);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const rd = describeResult(toolName, result);
|
|
621
|
+
if (rd) {
|
|
622
|
+
if (rd.meta) {
|
|
623
|
+
const m = document.createElement("div");
|
|
624
|
+
m.className = rd.statusClass
|
|
625
|
+
? `atl-http-badge ${rd.statusClass}`
|
|
626
|
+
: `atl-result-label${rd.type === "error" ? " error" : ""}`;
|
|
627
|
+
m.textContent = rd.meta;
|
|
628
|
+
resultEl.appendChild(m);
|
|
629
|
+
}
|
|
630
|
+
if (rd.text) {
|
|
631
|
+
const d = document.createElement("div");
|
|
632
|
+
d.className =
|
|
633
|
+
rd.type === "code"
|
|
634
|
+
? "atl-code"
|
|
635
|
+
: rd.type === "error"
|
|
636
|
+
? "atl-text error"
|
|
637
|
+
: rd.type === "success"
|
|
638
|
+
? "atl-success"
|
|
639
|
+
: "atl-text";
|
|
640
|
+
d.textContent = rd.text;
|
|
641
|
+
resultEl.appendChild(d);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Update summary with result snippet
|
|
646
|
+
const summary = info.el.querySelector(".atl-card-summary");
|
|
647
|
+
if (summary && rd?.text && rd.type !== "error") {
|
|
648
|
+
const snippet = rd.text.split("\n")[0].slice(0, 80);
|
|
649
|
+
if (snippet) summary.textContent = snippet;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
addResponse(content) {
|
|
654
|
+
if (!content) return;
|
|
655
|
+
this._clearEmpty();
|
|
656
|
+
|
|
657
|
+
const fakeId = `__resp_${Date.now()}`;
|
|
658
|
+
const stepEl = document.createElement("div");
|
|
659
|
+
stepEl.className = "atl-step";
|
|
660
|
+
stepEl.dataset.color = "response";
|
|
661
|
+
stepEl.id = `atl-step-${fakeId}`;
|
|
662
|
+
stepEl.innerHTML = `
|
|
663
|
+
<div class="atl-spine">
|
|
664
|
+
<div class="atl-dot">✅</div>
|
|
665
|
+
</div>
|
|
666
|
+
<div class="atl-card open" id="atl-card-${fakeId}">
|
|
667
|
+
<div class="atl-card-head" data-step="${fakeId}">
|
|
668
|
+
<span class="atl-card-label">Response</span>
|
|
669
|
+
<span class="atl-card-summary" style="font-style:italic;color:var(--text-muted);">final answer</span>
|
|
670
|
+
<span class="atl-toggle">▾</span>
|
|
671
|
+
</div>
|
|
672
|
+
<div class="atl-card-body">
|
|
673
|
+
<div class="atl-response-body md-content">${renderMarkdown(content)}</div>
|
|
674
|
+
</div>
|
|
675
|
+
</div>`;
|
|
676
|
+
|
|
677
|
+
this.feed.appendChild(stepEl);
|
|
678
|
+
this.steps.set(fakeId, {
|
|
679
|
+
el: stepEl,
|
|
680
|
+
cardEl: stepEl.querySelector(`#atl-card-${fakeId}`),
|
|
681
|
+
isResponse: true,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
stepEl.querySelector(".atl-card-head").addEventListener("click", () => {
|
|
685
|
+
stepEl.querySelector(`#atl-card-${fakeId}`).classList.toggle("open");
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
stepEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
_clearEmpty() {
|
|
692
|
+
const e = document.getElementById("activityEmpty");
|
|
693
|
+
if (e) e.style.display = "none";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
clear() {
|
|
697
|
+
this.steps.clear();
|
|
698
|
+
this.stepCount = 0;
|
|
699
|
+
// Remove everything except the empty state placeholder
|
|
700
|
+
const empty = document.getElementById("activityEmpty");
|
|
701
|
+
this.feed.innerHTML = "";
|
|
702
|
+
if (empty) {
|
|
703
|
+
this.feed.appendChild(empty);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
let activityTimeline = null;
|
|
709
|
+
let currentActivityRunId = null;
|
|
710
|
+
let currentActivityTimer = null;
|
|
711
|
+
let currentActivityStartTs = null;
|
|
712
|
+
|
|
713
|
+
function startRunTimer() {
|
|
714
|
+
clearInterval(currentActivityTimer);
|
|
715
|
+
currentActivityStartTs = Date.now();
|
|
716
|
+
const el = $("#atlTimer");
|
|
717
|
+
if (!el) return;
|
|
718
|
+
el.style.display = "inline-block";
|
|
719
|
+
el.textContent = "0s";
|
|
720
|
+
currentActivityTimer = setInterval(() => {
|
|
721
|
+
const s = Math.round((Date.now() - currentActivityStartTs) / 1000);
|
|
722
|
+
el.textContent = s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
723
|
+
}, 1000);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function stopRunTimer() {
|
|
727
|
+
clearInterval(currentActivityTimer);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function ensureTimeline() {
|
|
731
|
+
if (activityTimeline) return;
|
|
732
|
+
const feed = document.getElementById("activityFeed");
|
|
733
|
+
if (!feed) return;
|
|
734
|
+
activityTimeline = new ActivityTimeline(feed);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function addActivityNode(stepId, toolName, toolArgs, runId) {
|
|
738
|
+
if (runId && currentActivityRunId !== runId) return;
|
|
739
|
+
ensureTimeline();
|
|
740
|
+
activityTimeline.addNode(stepId, toolName, toolArgs);
|
|
741
|
+
const badge = $("#activityBadge");
|
|
742
|
+
if (badge) badge.classList.remove("hidden");
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function updateActivityNode(stepId, toolName, result, screenshotPath, status, runId) {
|
|
746
|
+
if (runId && currentActivityRunId !== runId) return;
|
|
747
|
+
if (activityTimeline)
|
|
748
|
+
activityTimeline.updateNode(
|
|
749
|
+
stepId,
|
|
750
|
+
toolName,
|
|
751
|
+
result,
|
|
752
|
+
screenshotPath,
|
|
753
|
+
status,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function addActivityResponse(content, runId) {
|
|
758
|
+
if (runId && currentActivityRunId !== runId) return;
|
|
759
|
+
ensureTimeline();
|
|
760
|
+
if (content) activityTimeline.addResponse(content);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function clearActivity() {
|
|
764
|
+
if (activityTimeline) activityTimeline.clear();
|
|
765
|
+
|
|
766
|
+
const empty = $("#activityEmpty");
|
|
767
|
+
if (empty) {
|
|
768
|
+
if (currentActivityRunId) {
|
|
769
|
+
empty.style.display = "none";
|
|
770
|
+
} else {
|
|
771
|
+
empty.style.display = "";
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── Activity History Panel ──
|
|
777
|
+
|
|
778
|
+
async function loadActivityHistory() {
|
|
779
|
+
const list = $("#activitySidebarList");
|
|
780
|
+
if (!list) return;
|
|
781
|
+
list.innerHTML = '<div class="activity-empty-text">Loading runs…</div>';
|
|
782
|
+
try {
|
|
783
|
+
const data = await api("/agents?limit=30");
|
|
784
|
+
if (!data.runs || data.runs.length === 0) {
|
|
785
|
+
list.innerHTML = '<div class="activity-empty-text">No past runs</div>';
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
list.innerHTML = "";
|
|
789
|
+
for (const run of data.runs) {
|
|
790
|
+
const card = document.createElement("div");
|
|
791
|
+
card.className = "ahp-run-card";
|
|
792
|
+
if (currentActivityRunId === run.id) card.classList.add("active");
|
|
793
|
+
card.dataset.runId = run.id;
|
|
794
|
+
const d = new Date(run.created_at);
|
|
795
|
+
const dateStr =
|
|
796
|
+
d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +
|
|
797
|
+
" " +
|
|
798
|
+
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
799
|
+
|
|
800
|
+
let badgeHtml = '';
|
|
801
|
+
if (run.status === 'running') badgeHtml = '<span class="ahp-run-status running">running</span>';
|
|
802
|
+
else if (run.status === 'failed') badgeHtml = '<span class="ahp-run-status failed">failed</span>';
|
|
803
|
+
else badgeHtml = '<span class="ahp-run-status completed">done</span>';
|
|
804
|
+
|
|
805
|
+
card.innerHTML = `<div class="ahp-run-title">${escapeHtml(run.title || "Untitled")}</div><div class="ahp-run-meta">${badgeHtml}<span>${dateStr}</span></div>`;
|
|
806
|
+
card.addEventListener("click", () => {
|
|
807
|
+
$$(".ahp-run-card.active").forEach((c) => c.classList.remove("active"));
|
|
808
|
+
card.classList.add("active");
|
|
809
|
+
loadRunOnCanvas(run.id, run.title, run.status);
|
|
810
|
+
});
|
|
811
|
+
list.appendChild(card);
|
|
812
|
+
}
|
|
813
|
+
} catch {
|
|
814
|
+
list.innerHTML = '<div class="activity-empty-text">Failed to load</div>';
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const refreshBtn = $("#activityRefreshBtn");
|
|
819
|
+
if (refreshBtn) refreshBtn.addEventListener("click", loadActivityHistory);
|
|
820
|
+
|
|
821
|
+
async function loadRunOnCanvas(runId, runTitle, runStatus) {
|
|
822
|
+
try {
|
|
823
|
+
currentActivityRunId = runId;
|
|
824
|
+
const titleEl = $("#activityRunTitle");
|
|
825
|
+
if (titleEl) titleEl.textContent = runTitle || `Run ${runId}`;
|
|
826
|
+
|
|
827
|
+
// reset badge
|
|
828
|
+
const badgeEl = $("#atlRunStatus");
|
|
829
|
+
if (badgeEl) {
|
|
830
|
+
badgeEl.style.display = "inline-block";
|
|
831
|
+
badgeEl.className = "atl-run-badge " + (runStatus === "running" ? "running" : "completed");
|
|
832
|
+
badgeEl.textContent = runStatus || "completed";
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// reset timer view
|
|
836
|
+
stopRunTimer();
|
|
837
|
+
const timerEl = $("#atlTimer");
|
|
838
|
+
if (timerEl) {
|
|
839
|
+
if (runStatus === "running") {
|
|
840
|
+
// If it was already running in the background, we might not have a perfect start time,
|
|
841
|
+
// but we can just start a fresh timer from now or fetch true duration later.
|
|
842
|
+
startRunTimer();
|
|
843
|
+
} else {
|
|
844
|
+
timerEl.style.display = "none";
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const data = await api(`/agents/${runId}/steps`);
|
|
849
|
+
clearActivity();
|
|
850
|
+
ensureTimeline();
|
|
851
|
+
|
|
852
|
+
// Explicitly hide empty block just in case
|
|
853
|
+
const empty = $("#activityEmpty");
|
|
854
|
+
if (empty) empty.style.display = "none";
|
|
855
|
+
|
|
856
|
+
for (const step of data.steps || []) {
|
|
857
|
+
let toolInput = {};
|
|
858
|
+
let result = null;
|
|
859
|
+
try {
|
|
860
|
+
toolInput = step.tool_input ? JSON.parse(step.tool_input) : {};
|
|
861
|
+
} catch { }
|
|
862
|
+
try {
|
|
863
|
+
result = step.result ? JSON.parse(step.result) : null;
|
|
864
|
+
} catch { }
|
|
865
|
+
activityTimeline.addNode(step.id, step.tool_name, toolInput);
|
|
866
|
+
activityTimeline.updateNode(
|
|
867
|
+
step.id,
|
|
868
|
+
step.tool_name,
|
|
869
|
+
result,
|
|
870
|
+
step.screenshot_path || null,
|
|
871
|
+
step.status,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
if (data.response) activityTimeline.addResponse(data.response);
|
|
875
|
+
} catch (err) {
|
|
876
|
+
toast("Failed to load run", "error");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Socket Events ──
|
|
881
|
+
|
|
882
|
+
socket.on("run:start", (data) => {
|
|
883
|
+
if (
|
|
884
|
+
data.triggerSource === "scheduler" ||
|
|
885
|
+
data.triggerSource === "heartbeat"
|
|
886
|
+
) {
|
|
887
|
+
backgroundRunIds.add(data.runId);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
setTimeout(loadActivityHistory, 100);
|
|
891
|
+
currentActivityRunId = data.runId;
|
|
892
|
+
const titleEl = $("#activityRunTitle");
|
|
893
|
+
if (titleEl) titleEl.textContent = data.title || `Run ${data.runId}`;
|
|
894
|
+
|
|
895
|
+
const badgeEl = $("#atlRunStatus");
|
|
896
|
+
if (badgeEl) {
|
|
897
|
+
badgeEl.style.display = "inline-block";
|
|
898
|
+
badgeEl.className = "atl-run-badge running";
|
|
899
|
+
badgeEl.textContent = "running";
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
clearActivity();
|
|
903
|
+
ensureTimeline();
|
|
904
|
+
startRunTimer();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
socket.on("run:thinking", (data) => {
|
|
908
|
+
if (backgroundRunIds.has(data.runId)) return;
|
|
909
|
+
const textEl = $("#thinkingText");
|
|
910
|
+
if (textEl) textEl.textContent = `Thinking… (step ${data.iteration})`;
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
socket.on("run:tool_start", (data) => {
|
|
914
|
+
if (backgroundRunIds.has(data.runId)) return;
|
|
915
|
+
addActivityNode(data.stepId, data.toolName, data.toolArgs, data.runId);
|
|
916
|
+
const textEl = $("#thinkingText");
|
|
917
|
+
if (textEl) textEl.textContent = `${data.toolName}…`;
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
socket.on("run:tool_end", (data) => {
|
|
921
|
+
if (backgroundRunIds.has(data.runId)) return;
|
|
922
|
+
updateActivityNode(
|
|
923
|
+
data.stepId,
|
|
924
|
+
data.toolName,
|
|
925
|
+
data.result,
|
|
926
|
+
data.screenshotPath,
|
|
927
|
+
data.status,
|
|
928
|
+
data.runId
|
|
929
|
+
);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
socket.on("run:stream", (data) => {
|
|
933
|
+
if (
|
|
934
|
+
backgroundRunIds.has(data.runId) ||
|
|
935
|
+
data.triggerSource === "scheduler" ||
|
|
936
|
+
data.triggerSource === "heartbeat" ||
|
|
937
|
+
data.triggerSource === "messaging"
|
|
938
|
+
)
|
|
939
|
+
return;
|
|
940
|
+
|
|
941
|
+
const text = data.content || data;
|
|
942
|
+
const chunks = text.split(/\n\n+/).filter(c => c.trim().length > 0 || c === text);
|
|
943
|
+
|
|
944
|
+
let streamContainer = $("#streamContainer");
|
|
945
|
+
if (!streamContainer) {
|
|
946
|
+
const thinking = $("#thinking");
|
|
947
|
+
if (thinking) thinking.remove();
|
|
948
|
+
|
|
949
|
+
streamContainer = document.createElement("div");
|
|
950
|
+
streamContainer.id = "streamContainer";
|
|
951
|
+
streamContainer.className = "chat-stream-group";
|
|
952
|
+
chatMessages.appendChild(streamContainer);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
streamContainer.innerHTML = "";
|
|
956
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
957
|
+
const div = document.createElement("div");
|
|
958
|
+
div.className = "chat-message assistant";
|
|
959
|
+
if (i > 0) div.style.marginTop = "8px";
|
|
960
|
+
div.innerHTML = `<div class="chat-avatar">${i === 0 ? 'N' : ''}</div><div class="chat-bubble md-content">${renderMarkdown(chunks[i])}</div>`;
|
|
961
|
+
streamContainer.appendChild(div);
|
|
962
|
+
}
|
|
963
|
+
requestAnimationFrame(renderMermaids);
|
|
964
|
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
socket.on("run:complete", (data) => {
|
|
968
|
+
const isBackground =
|
|
969
|
+
backgroundRunIds.has(data.runId) ||
|
|
970
|
+
data.triggerSource === "scheduler" ||
|
|
971
|
+
data.triggerSource === "heartbeat";
|
|
972
|
+
if (isBackground) backgroundRunIds.delete(data.runId);
|
|
973
|
+
|
|
974
|
+
if (!isBackground) {
|
|
975
|
+
const thinking = $("#thinking");
|
|
976
|
+
if (thinking) thinking.remove();
|
|
977
|
+
|
|
978
|
+
const streamContainer = $("#streamContainer");
|
|
979
|
+
if (streamContainer) {
|
|
980
|
+
streamContainer.id = "";
|
|
981
|
+
if (data.content) {
|
|
982
|
+
const chunks = data.content.split(/\n\n+/).filter(c => c.trim().length > 0 || c === data.content);
|
|
983
|
+
streamContainer.innerHTML = "";
|
|
984
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
985
|
+
const div = document.createElement("div");
|
|
986
|
+
div.className = "chat-message assistant";
|
|
987
|
+
if (i > 0) div.style.marginTop = "8px";
|
|
988
|
+
div.innerHTML = `<div class="chat-avatar">${i === 0 ? 'N' : ''}</div><div class="chat-bubble md-content">${renderMarkdown(chunks[i])}</div>`;
|
|
989
|
+
streamContainer.appendChild(div);
|
|
990
|
+
}
|
|
991
|
+
requestAnimationFrame(renderMermaids);
|
|
992
|
+
}
|
|
993
|
+
} else if (data.content && data.triggerSource !== "messaging") {
|
|
994
|
+
appendMessage("assistant", data.content);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
addActivityResponse(data.content, data.runId);
|
|
998
|
+
|
|
999
|
+
if (currentActivityRunId === data.runId) {
|
|
1000
|
+
const badgeEl = $("#atlRunStatus");
|
|
1001
|
+
if (badgeEl) {
|
|
1002
|
+
badgeEl.className = "atl-run-badge " + (data.status || "completed");
|
|
1003
|
+
badgeEl.textContent = data.status || "completed";
|
|
1004
|
+
}
|
|
1005
|
+
stopRunTimer();
|
|
1006
|
+
|
|
1007
|
+
// Collapse old steps for cleaner view
|
|
1008
|
+
if (activityTimeline) {
|
|
1009
|
+
for (const [, info] of activityTimeline.steps) {
|
|
1010
|
+
if (!info.isResponse && info.cardEl && info.cardEl.classList.contains("open")) {
|
|
1011
|
+
const hadError = info.cardEl.querySelector(".atl-text.error");
|
|
1012
|
+
if (!hadError) info.cardEl.classList.remove("open");
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
isStreaming = false;
|
|
1019
|
+
sendBtn.disabled = false;
|
|
1020
|
+
}
|
|
1021
|
+
setTimeout(loadActivityHistory, 100);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
socket.on("chat:cleared", () => {
|
|
1025
|
+
chatMessages.innerHTML = "";
|
|
1026
|
+
if (chatEmpty) chatEmpty.classList.remove("hidden");
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
socket.on("run:error", (data) => {
|
|
1030
|
+
const thinking = $("#thinking");
|
|
1031
|
+
if (thinking) thinking.remove();
|
|
1032
|
+
const errMsg = data.error || "Unknown error";
|
|
1033
|
+
appendMessage("assistant", `❌ ${errMsg}`);
|
|
1034
|
+
const badge = $("#activityBadge");
|
|
1035
|
+
if (badge) badge.classList.remove("hidden");
|
|
1036
|
+
isStreaming = false;
|
|
1037
|
+
sendBtn.disabled = false;
|
|
1038
|
+
toast(errMsg, "error");
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// AI sends a status update during a long task
|
|
1042
|
+
socket.on("run:interim", (data) => {
|
|
1043
|
+
const textEl = $("#thinkingText");
|
|
1044
|
+
if (textEl) textEl.textContent = data.message;
|
|
1045
|
+
appendInterimMessage(data.message);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
// Incoming social message → show in chat + activity canvas
|
|
1049
|
+
socket.on("messaging:message", (data) => {
|
|
1050
|
+
appendSocialMessage(data.platform, "user", data.content, data.senderName);
|
|
1051
|
+
ensureTimeline();
|
|
1052
|
+
const stepId = `msg-${Date.now()}`;
|
|
1053
|
+
activityTimeline.addNode(stepId, "send_message", {
|
|
1054
|
+
platform: data.platform,
|
|
1055
|
+
to: data.chatId,
|
|
1056
|
+
content: data.content,
|
|
1057
|
+
});
|
|
1058
|
+
activityTimeline.updateNode(
|
|
1059
|
+
stepId,
|
|
1060
|
+
"send_message",
|
|
1061
|
+
{ received: true, from: data.senderName },
|
|
1062
|
+
null,
|
|
1063
|
+
"completed",
|
|
1064
|
+
);
|
|
1065
|
+
const badge = $("#activityBadge");
|
|
1066
|
+
if (badge) badge.classList.remove("hidden");
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
// ── Logs Tab ──
|
|
1070
|
+
|
|
1071
|
+
const logsContainer = $("#logsContainer");
|
|
1072
|
+
let logsRequested = false;
|
|
1073
|
+
|
|
1074
|
+
function loadLogsPage() {
|
|
1075
|
+
if (!logsRequested) {
|
|
1076
|
+
socket.emit("client:request_logs");
|
|
1077
|
+
logsRequested = true;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function appendLogEntry(log) {
|
|
1082
|
+
if (!logsContainer) return;
|
|
1083
|
+
|
|
1084
|
+
const isAtBottom =
|
|
1085
|
+
logsContainer.scrollHeight - logsContainer.scrollTop <=
|
|
1086
|
+
logsContainer.clientHeight + 50;
|
|
1087
|
+
|
|
1088
|
+
const el = document.createElement("div");
|
|
1089
|
+
el.style.marginBottom = "4px";
|
|
1090
|
+
el.style.borderBottom = "1px solid #1e293b";
|
|
1091
|
+
el.style.paddingBottom = "4px";
|
|
1092
|
+
el.style.wordBreak = "break-word";
|
|
1093
|
+
el.style.whiteSpace = "pre-wrap";
|
|
1094
|
+
|
|
1095
|
+
let color = "#e2e8f0"; // info
|
|
1096
|
+
if (log.type === "error")
|
|
1097
|
+
color = "#f87171"; // red
|
|
1098
|
+
else if (log.type === "warn")
|
|
1099
|
+
color = "#fbbf24"; // yellow
|
|
1100
|
+
else if (log.type === "log") color = "#94a3b8"; // gray
|
|
1101
|
+
|
|
1102
|
+
const timeStr = new Date(log.timestamp).toLocaleTimeString([], {
|
|
1103
|
+
hour12: false,
|
|
1104
|
+
});
|
|
1105
|
+
el.innerHTML = `<span style="color:#64748b;margin-right:8px;">[${timeStr}]</span><span style="color:${color};">${escapeHtml(log.message)}</span>`;
|
|
1106
|
+
|
|
1107
|
+
logsContainer.appendChild(el);
|
|
1108
|
+
|
|
1109
|
+
if (isAtBottom) {
|
|
1110
|
+
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
socket.on("server:log", (log) => {
|
|
1115
|
+
appendLogEntry(log);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
socket.on("server:log_history", (history) => {
|
|
1119
|
+
if (logsContainer) {
|
|
1120
|
+
logsContainer.innerHTML = "";
|
|
1121
|
+
history.forEach(appendLogEntry);
|
|
1122
|
+
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
const clearLogsBtn = $("#clearLogsBtn");
|
|
1127
|
+
if (clearLogsBtn) {
|
|
1128
|
+
clearLogsBtn.addEventListener("click", () => {
|
|
1129
|
+
if (logsContainer) logsContainer.innerHTML = "";
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const copyLogsBtn = $("#copyLogsBtn");
|
|
1134
|
+
if (copyLogsBtn) {
|
|
1135
|
+
copyLogsBtn.addEventListener("click", async () => {
|
|
1136
|
+
try {
|
|
1137
|
+
let debugText = "=== SYSTEM DEBUG INFO ===\\n\\n";
|
|
1138
|
+
|
|
1139
|
+
debugText += "--- CHAT HISTORY ---\\n";
|
|
1140
|
+
const chats = document.querySelectorAll(".chat-message");
|
|
1141
|
+
chats.forEach((c) => {
|
|
1142
|
+
const sender = c.classList.contains("user") ? "USER" : "AI";
|
|
1143
|
+
const content =
|
|
1144
|
+
c.querySelector(".md-content")?.innerText || c.innerText;
|
|
1145
|
+
debugText += `[${sender}]\\n${content.trim()}\\n\\n`;
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
debugText += "--- ACTIVITY TIMELINE ---\\n";
|
|
1149
|
+
const nodes = document.querySelectorAll(".node-view");
|
|
1150
|
+
nodes.forEach((n) => {
|
|
1151
|
+
const title = n.querySelector(".node-title")?.innerText || "Node";
|
|
1152
|
+
const details =
|
|
1153
|
+
n.querySelector(".node-details")?.innerText || "No details";
|
|
1154
|
+
debugText += `${title}\\n${details}\\n\\n`;
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
debugText += "--- CONSOLE LOGS ---\\n";
|
|
1158
|
+
if (logsContainer) {
|
|
1159
|
+
debugText += logsContainer.innerText || "No logs available.";
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
await navigator.clipboard.writeText(debugText);
|
|
1163
|
+
|
|
1164
|
+
const originalText = copyLogsBtn.textContent;
|
|
1165
|
+
copyLogsBtn.textContent = "Copied!";
|
|
1166
|
+
setTimeout(() => {
|
|
1167
|
+
copyLogsBtn.textContent = originalText;
|
|
1168
|
+
}, 2000);
|
|
1169
|
+
|
|
1170
|
+
toast("Debug info copied to clipboard", "success");
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
toast("Failed to copy debug info: " + err.message, "error");
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// ── Settings ──
|
|
1178
|
+
|
|
1179
|
+
$("#settingsBtn").addEventListener("click", async () => {
|
|
1180
|
+
try {
|
|
1181
|
+
const meta = await api("/settings/meta/models");
|
|
1182
|
+
const settings = await api("/settings");
|
|
1183
|
+
|
|
1184
|
+
$("#settingHeartbeat").checked =
|
|
1185
|
+
settings.heartbeat_enabled === true ||
|
|
1186
|
+
settings.heartbeat_enabled === "true";
|
|
1187
|
+
$("#settingHeadlessBrowser").checked =
|
|
1188
|
+
settings.headless_browser !== false &&
|
|
1189
|
+
settings.headless_browser !== "false";
|
|
1190
|
+
|
|
1191
|
+
const enabledModels = Array.isArray(settings.enabled_models) ? settings.enabled_models : (meta.models || []).map(m => m.id);
|
|
1192
|
+
|
|
1193
|
+
const chatModelSelect = $("#settingDefaultChatModel");
|
|
1194
|
+
const subagentModelSelect = $("#settingDefaultSubagentModel");
|
|
1195
|
+
|
|
1196
|
+
if (chatModelSelect && subagentModelSelect && meta.models) {
|
|
1197
|
+
chatModelSelect.innerHTML = '<option value="auto">Smart Selector (Auto)</option>';
|
|
1198
|
+
subagentModelSelect.innerHTML = '<option value="auto">Smart Selector (Auto)</option>';
|
|
1199
|
+
|
|
1200
|
+
for (const modelDef of meta.models) {
|
|
1201
|
+
const chatOption = document.createElement("option");
|
|
1202
|
+
chatOption.value = modelDef.id;
|
|
1203
|
+
chatOption.textContent = modelDef.label;
|
|
1204
|
+
chatModelSelect.appendChild(chatOption);
|
|
1205
|
+
|
|
1206
|
+
const subagentOption = document.createElement("option");
|
|
1207
|
+
subagentOption.value = modelDef.id;
|
|
1208
|
+
subagentOption.textContent = modelDef.label;
|
|
1209
|
+
subagentModelSelect.appendChild(subagentOption);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
chatModelSelect.value = settings.default_chat_model || "auto";
|
|
1213
|
+
subagentModelSelect.value = settings.default_subagent_model || "auto";
|
|
1214
|
+
|
|
1215
|
+
const indicator = $("#modelIndicator");
|
|
1216
|
+
if (indicator) {
|
|
1217
|
+
if (settings.default_chat_model && settings.default_chat_model !== "auto") {
|
|
1218
|
+
const selectedModel = meta.models.find(m => m.id === settings.default_chat_model);
|
|
1219
|
+
indicator.textContent = selectedModel ? selectedModel.label : "Smart Selector Active";
|
|
1220
|
+
} else {
|
|
1221
|
+
indicator.textContent = "Smart Selector Active";
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const container = $("#modelCheckboxesContainer");
|
|
1227
|
+
if (container) {
|
|
1228
|
+
container.innerHTML = "";
|
|
1229
|
+
if (meta.models) {
|
|
1230
|
+
for (const modelDef of meta.models) {
|
|
1231
|
+
const label = document.createElement("label");
|
|
1232
|
+
label.className = "flex items-center gap-2";
|
|
1233
|
+
label.style.cursor = "pointer";
|
|
1234
|
+
|
|
1235
|
+
const checkbox = document.createElement("input");
|
|
1236
|
+
checkbox.type = "checkbox";
|
|
1237
|
+
checkbox.className = "dynamic-model-checkbox";
|
|
1238
|
+
checkbox.dataset.modelId = modelDef.id;
|
|
1239
|
+
checkbox.checked = enabledModels.includes(modelDef.id);
|
|
1240
|
+
|
|
1241
|
+
const span = document.createElement("span");
|
|
1242
|
+
span.textContent = modelDef.label;
|
|
1243
|
+
|
|
1244
|
+
label.appendChild(checkbox);
|
|
1245
|
+
label.appendChild(span);
|
|
1246
|
+
container.appendChild(label);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
console.error("Failed to load settings:", err);
|
|
1252
|
+
$("#settingHeadlessBrowser").checked = true; // default headless
|
|
1253
|
+
}
|
|
1254
|
+
$("#settingsModal").classList.remove("hidden");
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
$("#closeSettings").addEventListener("click", () =>
|
|
1258
|
+
$("#settingsModal").classList.add("hidden"),
|
|
1259
|
+
);
|
|
1260
|
+
$("#cancelSettings").addEventListener("click", () =>
|
|
1261
|
+
$("#settingsModal").classList.add("hidden"),
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
$("#saveSettings").addEventListener("click", async () => {
|
|
1265
|
+
try {
|
|
1266
|
+
const enabledModels = Array.from(document.querySelectorAll("#modelCheckboxesContainer .dynamic-model-checkbox"))
|
|
1267
|
+
.filter(cb => cb.checked)
|
|
1268
|
+
.map(cb => cb.dataset.modelId);
|
|
1269
|
+
|
|
1270
|
+
const defaultChatModel = $("#settingDefaultChatModel").value;
|
|
1271
|
+
const defaultSubagentModel = $("#settingDefaultSubagentModel").value;
|
|
1272
|
+
|
|
1273
|
+
await api("/settings", {
|
|
1274
|
+
method: "PUT",
|
|
1275
|
+
body: {
|
|
1276
|
+
heartbeat_enabled: $("#settingHeartbeat").checked,
|
|
1277
|
+
headless_browser: $("#settingHeadlessBrowser").checked,
|
|
1278
|
+
enabled_models: enabledModels,
|
|
1279
|
+
default_chat_model: defaultChatModel,
|
|
1280
|
+
default_subagent_model: defaultSubagentModel
|
|
1281
|
+
},
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
const indicator = $("#modelIndicator");
|
|
1285
|
+
if (indicator) {
|
|
1286
|
+
if (defaultChatModel !== "auto") {
|
|
1287
|
+
const selectedOption = $("#settingDefaultChatModel").options[$("#settingDefaultChatModel").selectedIndex];
|
|
1288
|
+
indicator.textContent = selectedOption ? selectedOption.text : "Smart Selector Active";
|
|
1289
|
+
} else {
|
|
1290
|
+
indicator.textContent = "Smart Selector Active";
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
$("#settingsModal").classList.add("hidden");
|
|
1295
|
+
toast("Settings saved", "success");
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
toast("Failed to save settings", "error");
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
$("#updateAppBtn").addEventListener("click", async () => {
|
|
1302
|
+
if (!confirm("Are you sure you want to run the update script? This will trigger neoagent update and restart the server.")) return;
|
|
1303
|
+
try {
|
|
1304
|
+
const btn = $("#updateAppBtn");
|
|
1305
|
+
btn.disabled = true;
|
|
1306
|
+
btn.textContent = "Updating...";
|
|
1307
|
+
await api("/settings/update", { method: "POST" });
|
|
1308
|
+
toast("Update started! Please wait for the application to restart.", "success");
|
|
1309
|
+
$("#settingsModal").classList.add("hidden");
|
|
1310
|
+
// Give it a few seconds before resetting the UI locally just in case
|
|
1311
|
+
setTimeout(() => {
|
|
1312
|
+
btn.disabled = false;
|
|
1313
|
+
btn.textContent = "Update App";
|
|
1314
|
+
}, 10000);
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
toast("Failed to trigger update: " + err.message, "error");
|
|
1317
|
+
$("#updateAppBtn").disabled = false;
|
|
1318
|
+
$("#updateAppBtn").textContent = "Update App";
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// ── Logout ──
|
|
1323
|
+
|
|
1324
|
+
$("#logoutBtn").addEventListener("click", async () => {
|
|
1325
|
+
try {
|
|
1326
|
+
await fetch("/api/auth/logout", { method: "POST" });
|
|
1327
|
+
window.location.href = "/login";
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
window.location.href = "/login";
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
// ── Memory Page ──
|
|
1334
|
+
|
|
1335
|
+
// Category badge colours
|
|
1336
|
+
const CAT_COLORS = {
|
|
1337
|
+
user_fact: {
|
|
1338
|
+
bg: "#3b82f620",
|
|
1339
|
+
border: "#3b82f6",
|
|
1340
|
+
text: "#3b82f6",
|
|
1341
|
+
label: "User Fact",
|
|
1342
|
+
},
|
|
1343
|
+
preference: {
|
|
1344
|
+
bg: "#8b5cf620",
|
|
1345
|
+
border: "#8b5cf6",
|
|
1346
|
+
text: "#8b5cf6",
|
|
1347
|
+
label: "Preference",
|
|
1348
|
+
},
|
|
1349
|
+
personality: {
|
|
1350
|
+
bg: "#ec489920",
|
|
1351
|
+
border: "#ec4899",
|
|
1352
|
+
text: "#ec4899",
|
|
1353
|
+
label: "Personality",
|
|
1354
|
+
},
|
|
1355
|
+
episodic: {
|
|
1356
|
+
bg: "#22c55e20",
|
|
1357
|
+
border: "#22c55e",
|
|
1358
|
+
text: "#22c55e",
|
|
1359
|
+
label: "Episodic",
|
|
1360
|
+
},
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
let _memActiveCategory = "";
|
|
1364
|
+
let _memCurrentPage = 0;
|
|
1365
|
+
|
|
1366
|
+
async function loadMemoryPage() {
|
|
1367
|
+
try {
|
|
1368
|
+
const data = await api("/memory");
|
|
1369
|
+
|
|
1370
|
+
// Soul
|
|
1371
|
+
if ($("#soulEditor")) $("#soulEditor").value = data.soul || "";
|
|
1372
|
+
|
|
1373
|
+
// Daily logs
|
|
1374
|
+
const dailyContainer = $("#dailyLogs");
|
|
1375
|
+
if (dailyContainer) {
|
|
1376
|
+
dailyContainer.innerHTML = "";
|
|
1377
|
+
for (const log of data.dailyLogs || []) {
|
|
1378
|
+
const card = document.createElement("div");
|
|
1379
|
+
card.className = "item-card";
|
|
1380
|
+
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>`;
|
|
1381
|
+
dailyContainer.appendChild(card);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Core memory
|
|
1386
|
+
_renderCoreMemory(data.coreMemory || {});
|
|
1387
|
+
|
|
1388
|
+
// API keys
|
|
1389
|
+
const keyContainer = $("#apiKeyList");
|
|
1390
|
+
if (keyContainer) {
|
|
1391
|
+
keyContainer.innerHTML = "";
|
|
1392
|
+
const keys = await api("/memory/api-keys");
|
|
1393
|
+
for (const [name, masked] of Object.entries(keys)) {
|
|
1394
|
+
const card = document.createElement("div");
|
|
1395
|
+
card.className = "item-card flex justify-between items-center";
|
|
1396
|
+
card.innerHTML = `<div><div class="item-card-title">${escapeHtml(name)}</div><div class="item-card-meta font-mono">${escapeHtml(masked)}</div></div>
|
|
1397
|
+
<button class="btn btn-sm btn-danger" data-action="deleteApiKey" data-name="${escapeHtml(name)}">×</button>`;
|
|
1398
|
+
keyContainer.appendChild(card);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Memories list
|
|
1403
|
+
await _loadMemoriesTab(_memActiveCategory);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
toast("Failed to load memory", "error");
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
async function _loadMemoriesTab(category = "") {
|
|
1410
|
+
const container = $("#memoryList");
|
|
1411
|
+
if (!container) return;
|
|
1412
|
+
container.innerHTML =
|
|
1413
|
+
'<div class="empty-state" style="grid-column:1/-1"><p>Loading…</p></div>';
|
|
1414
|
+
try {
|
|
1415
|
+
const params = new URLSearchParams({ limit: 60, offset: 0 });
|
|
1416
|
+
if (category) params.set("category", category);
|
|
1417
|
+
const memories = await api(`/memory/memories?${params}`);
|
|
1418
|
+
_renderMemories(memories, container);
|
|
1419
|
+
} catch {
|
|
1420
|
+
container.innerHTML =
|
|
1421
|
+
'<div class="empty-state" style="grid-column:1/-1"><p>Failed to load memories</p></div>';
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function _renderMemories(memories, container) {
|
|
1426
|
+
container.innerHTML = "";
|
|
1427
|
+
if (!memories.length) {
|
|
1428
|
+
container.innerHTML =
|
|
1429
|
+
'<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>';
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
for (const mem of memories) {
|
|
1433
|
+
const cat = CAT_COLORS[mem.category] || CAT_COLORS.episodic;
|
|
1434
|
+
const dots =
|
|
1435
|
+
"●".repeat(Math.round(mem.importance / 2)) +
|
|
1436
|
+
"○".repeat(5 - Math.round(mem.importance / 2));
|
|
1437
|
+
const date = new Date(mem.updated_at || mem.created_at);
|
|
1438
|
+
const dateStr = date.toLocaleDateString("en-US", {
|
|
1439
|
+
month: "short",
|
|
1440
|
+
day: "numeric",
|
|
1441
|
+
year: "2-digit",
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
const card = document.createElement("div");
|
|
1445
|
+
card.className = "card";
|
|
1446
|
+
card.style.cssText = `margin:0;cursor:default;border-left:3px solid ${cat.border};`;
|
|
1447
|
+
card.innerHTML = `
|
|
1448
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px;">
|
|
1449
|
+
<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>
|
|
1450
|
+
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0;">
|
|
1451
|
+
<span style="font-size:0.75rem;color:var(--text-muted);">${dateStr}</span>
|
|
1452
|
+
<button class="btn btn-sm btn-danger" data-action="deleteMemory" data-id="${escapeHtml(mem.id)}" style="padding:2px 7px;font-size:0.75rem;">×</button>
|
|
1453
|
+
</div>
|
|
1454
|
+
</div>
|
|
1455
|
+
<div style="font-size:0.9rem;line-height:1.5;color:var(--text);">${escapeHtml(mem.content)}</div>
|
|
1456
|
+
<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>`;
|
|
1457
|
+
container.appendChild(card);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function _renderCoreMemory(core) {
|
|
1462
|
+
const container = $("#coreMemoryList");
|
|
1463
|
+
if (!container) return;
|
|
1464
|
+
container.innerHTML = "";
|
|
1465
|
+
if (!Object.keys(core).length) {
|
|
1466
|
+
const empty = document.createElement("p");
|
|
1467
|
+
empty.className = "text-muted";
|
|
1468
|
+
empty.style.cssText = "font-size:0.85rem;margin-bottom:8px;";
|
|
1469
|
+
empty.textContent = "No core memory entries yet.";
|
|
1470
|
+
container.appendChild(empty);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
for (const [key, val] of Object.entries(core)) {
|
|
1474
|
+
const row = document.createElement("div");
|
|
1475
|
+
row.className = "item-card";
|
|
1476
|
+
row.style.marginBottom = "8px";
|
|
1477
|
+
const display = typeof val === "object" ? JSON.stringify(val) : String(val);
|
|
1478
|
+
row.innerHTML = `
|
|
1479
|
+
<div class="item-card-header">
|
|
1480
|
+
<div>
|
|
1481
|
+
<div class="item-card-title" style="font-size:0.85rem;font-family:monospace;">${escapeHtml(key)}</div>
|
|
1482
|
+
<div class="item-card-meta" style="margin-top:3px;">${escapeHtml(display.slice(0, 200))}</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
<div class="item-card-actions">
|
|
1485
|
+
<button class="btn btn-sm btn-secondary" data-action="editCore" data-key="${escapeHtml(key)}" data-val="${escapeHtml(display)}">Edit</button>
|
|
1486
|
+
<button class="btn btn-sm btn-danger" data-action="deleteCore" data-key="${escapeHtml(key)}">×</button>
|
|
1487
|
+
</div>
|
|
1488
|
+
</div>`;
|
|
1489
|
+
container.appendChild(row);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Tab switching for memory page
|
|
1494
|
+
$$("[data-mem-tab]").forEach((tab) => {
|
|
1495
|
+
tab.addEventListener("click", () => {
|
|
1496
|
+
$$("[data-mem-tab]").forEach((t) => t.classList.remove("active"));
|
|
1497
|
+
$$(".mem-panel").forEach((p) => p.classList.remove("active"));
|
|
1498
|
+
tab.classList.add("active");
|
|
1499
|
+
$(`#mem-${tab.dataset.memTab}`)?.classList.add("active");
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
// Category filter
|
|
1504
|
+
$("#memoryCategoryFilter")?.addEventListener("click", async (e) => {
|
|
1505
|
+
const btn = e.target.closest("[data-cat]");
|
|
1506
|
+
if (!btn) return;
|
|
1507
|
+
_memActiveCategory = btn.dataset.cat;
|
|
1508
|
+
$$("#memoryCategoryFilter [data-cat]").forEach((b) => {
|
|
1509
|
+
b.className =
|
|
1510
|
+
b.dataset.cat === _memActiveCategory
|
|
1511
|
+
? "btn btn-sm btn-primary"
|
|
1512
|
+
: "btn btn-sm btn-secondary";
|
|
1513
|
+
});
|
|
1514
|
+
await _loadMemoriesTab(_memActiveCategory);
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
// Semantic search
|
|
1518
|
+
$("#memorySearchBtn")?.addEventListener("click", async () => {
|
|
1519
|
+
const q = $("#memorySearchInput")?.value?.trim();
|
|
1520
|
+
if (!q) {
|
|
1521
|
+
await _loadMemoriesTab(_memActiveCategory);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
const container = $("#memoryList");
|
|
1525
|
+
container.innerHTML =
|
|
1526
|
+
'<div class="empty-state" style="grid-column:1/-1"><p>Searching…</p></div>';
|
|
1527
|
+
try {
|
|
1528
|
+
const results = await api("/memory/memories/recall", {
|
|
1529
|
+
method: "POST",
|
|
1530
|
+
body: { query: q, limit: 20 },
|
|
1531
|
+
});
|
|
1532
|
+
_renderMemories(results, container);
|
|
1533
|
+
} catch {
|
|
1534
|
+
container.innerHTML =
|
|
1535
|
+
'<div class="empty-state" style="grid-column:1/-1"><p>Search failed</p></div>';
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
$("#memorySearchInput")?.addEventListener("keydown", (e) => {
|
|
1540
|
+
if (e.key === "Enter") $("#memorySearchBtn")?.click();
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
// Soul save
|
|
1544
|
+
$("#saveSoulBtn")?.addEventListener("click", async () => {
|
|
1545
|
+
try {
|
|
1546
|
+
await api("/memory/soul", {
|
|
1547
|
+
method: "PUT",
|
|
1548
|
+
body: { content: $("#soulEditor").value },
|
|
1549
|
+
});
|
|
1550
|
+
toast("Soul saved", "success");
|
|
1551
|
+
} catch {
|
|
1552
|
+
toast("Failed to save", "error");
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
// Add Memory Modal
|
|
1557
|
+
$("#addMemoryBtn")?.addEventListener("click", () => {
|
|
1558
|
+
$("#addMemoryModal")?.classList.remove("hidden");
|
|
1559
|
+
});
|
|
1560
|
+
$("#closeAddMemory")?.addEventListener("click", () =>
|
|
1561
|
+
$("#addMemoryModal")?.classList.add("hidden"),
|
|
1562
|
+
);
|
|
1563
|
+
$("#cancelAddMemory")?.addEventListener("click", () =>
|
|
1564
|
+
$("#addMemoryModal")?.classList.add("hidden"),
|
|
1565
|
+
);
|
|
1566
|
+
|
|
1567
|
+
$("#confirmAddMemory")?.addEventListener("click", async () => {
|
|
1568
|
+
const content = $("#newMemoryContent")?.value?.trim();
|
|
1569
|
+
if (!content) {
|
|
1570
|
+
toast("Content is required", "error");
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const category = $("#newMemoryCategory")?.value || "episodic";
|
|
1574
|
+
const importance = parseInt($("#newMemoryImportance")?.value) || 5;
|
|
1575
|
+
try {
|
|
1576
|
+
await api("/memory/memories", {
|
|
1577
|
+
method: "POST",
|
|
1578
|
+
body: { content, category, importance },
|
|
1579
|
+
});
|
|
1580
|
+
$("#addMemoryModal")?.classList.add("hidden");
|
|
1581
|
+
$("#newMemoryContent").value = "";
|
|
1582
|
+
await _loadMemoriesTab(_memActiveCategory);
|
|
1583
|
+
toast("Memory saved", "success");
|
|
1584
|
+
} catch {
|
|
1585
|
+
toast("Failed to save memory", "error");
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
// Set core memory key
|
|
1590
|
+
$("#setCoreBtn")?.addEventListener("click", async () => {
|
|
1591
|
+
const key = $("#coreKeySelect")?.value;
|
|
1592
|
+
const value = $("#coreValueInput")?.value?.trim();
|
|
1593
|
+
if (!key || !value) {
|
|
1594
|
+
toast("Key and value are required", "error");
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
await api(`/memory/core/${key}`, { method: "PUT", body: { value } });
|
|
1599
|
+
$("#coreValueInput").value = "";
|
|
1600
|
+
const core = await api("/memory/core");
|
|
1601
|
+
_renderCoreMemory(core);
|
|
1602
|
+
toast("Core memory updated", "success");
|
|
1603
|
+
} catch {
|
|
1604
|
+
toast("Failed to update core memory", "error");
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
// API Keys
|
|
1609
|
+
window.deleteApiKey = async (name) => {
|
|
1610
|
+
try {
|
|
1611
|
+
await api(`/memory/api-keys/${name}`, { method: "DELETE" });
|
|
1612
|
+
loadMemoryPage();
|
|
1613
|
+
toast("Key deleted", "success");
|
|
1614
|
+
} catch {
|
|
1615
|
+
toast("Failed to delete", "error");
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
|
|
1619
|
+
$("#addApiKeyBtn")?.addEventListener("click", () => {
|
|
1620
|
+
const name = prompt("Service name:");
|
|
1621
|
+
if (!name) return;
|
|
1622
|
+
const key = prompt("API key value:");
|
|
1623
|
+
if (!key) return;
|
|
1624
|
+
api(`/memory/api-keys/${name}`, { method: "PUT", body: { key } })
|
|
1625
|
+
.then(() => {
|
|
1626
|
+
loadMemoryPage();
|
|
1627
|
+
toast("Key added", "success");
|
|
1628
|
+
})
|
|
1629
|
+
.catch(() => toast("Failed to add key", "error"));
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
// Global click delegation for memory actions
|
|
1633
|
+
document.addEventListener("click", async (e) => {
|
|
1634
|
+
const btn = e.target.closest("[data-action]");
|
|
1635
|
+
if (!btn) return;
|
|
1636
|
+
const action = btn.dataset.action;
|
|
1637
|
+
|
|
1638
|
+
if (action === "deleteApiKey") {
|
|
1639
|
+
window.deleteApiKey(btn.dataset.name);
|
|
1640
|
+
} else if (action === "deleteMemory") {
|
|
1641
|
+
if (!confirm("Delete this memory?")) return;
|
|
1642
|
+
try {
|
|
1643
|
+
await api(`/memory/memories/${btn.dataset.id}`, { method: "DELETE" });
|
|
1644
|
+
await _loadMemoriesTab(_memActiveCategory);
|
|
1645
|
+
toast("Memory deleted", "success");
|
|
1646
|
+
} catch {
|
|
1647
|
+
toast("Failed to delete", "error");
|
|
1648
|
+
}
|
|
1649
|
+
} else if (action === "editCore") {
|
|
1650
|
+
const newVal = prompt(`Edit ${btn.dataset.key}:`, btn.dataset.val);
|
|
1651
|
+
if (newVal === null) return;
|
|
1652
|
+
try {
|
|
1653
|
+
await api(`/memory/core/${btn.dataset.key}`, {
|
|
1654
|
+
method: "PUT",
|
|
1655
|
+
body: { value: newVal },
|
|
1656
|
+
});
|
|
1657
|
+
const core = await api("/memory/core");
|
|
1658
|
+
_renderCoreMemory(core);
|
|
1659
|
+
toast("Updated", "success");
|
|
1660
|
+
} catch {
|
|
1661
|
+
toast("Failed to update", "error");
|
|
1662
|
+
}
|
|
1663
|
+
} else if (action === "deleteCore") {
|
|
1664
|
+
if (!confirm(`Delete core key "${btn.dataset.key}"?`)) return;
|
|
1665
|
+
try {
|
|
1666
|
+
await api(`/memory/core/${btn.dataset.key}`, { method: "DELETE" });
|
|
1667
|
+
const core = await api("/memory/core");
|
|
1668
|
+
_renderCoreMemory(core);
|
|
1669
|
+
toast("Deleted", "success");
|
|
1670
|
+
} catch {
|
|
1671
|
+
toast("Failed to delete", "error");
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
// ── Skills Page ──
|
|
1677
|
+
|
|
1678
|
+
// Tab switching for skills page
|
|
1679
|
+
document.querySelectorAll("[data-skills-tab]").forEach((tab) => {
|
|
1680
|
+
tab.addEventListener("click", () => {
|
|
1681
|
+
document
|
|
1682
|
+
.querySelectorAll("[data-skills-tab]")
|
|
1683
|
+
.forEach((t) => t.classList.remove("active"));
|
|
1684
|
+
tab.classList.add("active");
|
|
1685
|
+
const which = tab.dataset.skillsTab;
|
|
1686
|
+
$("#skillList").classList.toggle("hidden", which !== "installed");
|
|
1687
|
+
$("#skillStore").classList.toggle("hidden", which !== "store");
|
|
1688
|
+
if (which === "store") loadSkillStore();
|
|
1689
|
+
else loadSkillsPage();
|
|
1690
|
+
});
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
async function loadSkillStore() {
|
|
1694
|
+
const wrap = $("#skillStore");
|
|
1695
|
+
wrap.innerHTML = '<div class="empty-state"><p>Loading store…</p></div>';
|
|
1696
|
+
try {
|
|
1697
|
+
const items = await api("/store");
|
|
1698
|
+
|
|
1699
|
+
// Build category groups
|
|
1700
|
+
const cats = {};
|
|
1701
|
+
for (const item of items) {
|
|
1702
|
+
if (!cats[item.category]) cats[item.category] = [];
|
|
1703
|
+
cats[item.category].push(item);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const CAT_LABELS = {
|
|
1707
|
+
system: "⚙️ System",
|
|
1708
|
+
network: "📡 Network",
|
|
1709
|
+
info: "ℹ️ Info",
|
|
1710
|
+
dev: "🛠 Dev",
|
|
1711
|
+
productivity: "🗂 Productivity",
|
|
1712
|
+
fun: "🎲 Fun",
|
|
1713
|
+
maker: "🖨️ Maker",
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
wrap.innerHTML = "";
|
|
1717
|
+
|
|
1718
|
+
// Search input
|
|
1719
|
+
const searchRow = document.createElement("div");
|
|
1720
|
+
searchRow.style.cssText = "margin-bottom:16px;";
|
|
1721
|
+
const searchInp = document.createElement("input");
|
|
1722
|
+
searchInp.type = "text";
|
|
1723
|
+
searchInp.className = "input";
|
|
1724
|
+
searchInp.placeholder = "Search skills…";
|
|
1725
|
+
searchRow.appendChild(searchInp);
|
|
1726
|
+
wrap.appendChild(searchRow);
|
|
1727
|
+
|
|
1728
|
+
const cardsWrap = document.createElement("div");
|
|
1729
|
+
wrap.appendChild(cardsWrap);
|
|
1730
|
+
|
|
1731
|
+
function renderStore(filter) {
|
|
1732
|
+
cardsWrap.innerHTML = "";
|
|
1733
|
+
let totalShown = 0;
|
|
1734
|
+
for (const [cat, catItems] of Object.entries(cats)) {
|
|
1735
|
+
const visible = catItems.filter(
|
|
1736
|
+
(i) =>
|
|
1737
|
+
!filter ||
|
|
1738
|
+
i.name.toLowerCase().includes(filter) ||
|
|
1739
|
+
i.description.toLowerCase().includes(filter),
|
|
1740
|
+
);
|
|
1741
|
+
if (!visible.length) continue;
|
|
1742
|
+
totalShown += visible.length;
|
|
1743
|
+
|
|
1744
|
+
const section = document.createElement("div");
|
|
1745
|
+
section.style.cssText = "margin-bottom:28px;";
|
|
1746
|
+
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>`;
|
|
1747
|
+
|
|
1748
|
+
const grid = document.createElement("div");
|
|
1749
|
+
grid.style.cssText =
|
|
1750
|
+
"display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:10px;";
|
|
1751
|
+
|
|
1752
|
+
for (const item of visible) {
|
|
1753
|
+
const card = document.createElement("div");
|
|
1754
|
+
card.className = "card";
|
|
1755
|
+
card.style.cssText =
|
|
1756
|
+
"display:flex;flex-direction:column;gap:8px;padding:14px;";
|
|
1757
|
+
card.innerHTML = `
|
|
1758
|
+
<div style="display:flex;align-items:center;gap:10px;">
|
|
1759
|
+
<span style="font-size:1.6rem;line-height:1;">${item.icon}</span>
|
|
1760
|
+
<div style="flex:1;min-width:0;">
|
|
1761
|
+
<div style="font-weight:600;font-size:0.95rem;">${escapeHtml(item.name)}</div>
|
|
1762
|
+
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:2px;">${escapeHtml(item.description)}</div>
|
|
1763
|
+
</div>
|
|
1764
|
+
</div>
|
|
1765
|
+
<div style="display:flex;justify-content:flex-end;">
|
|
1766
|
+
${item.installed
|
|
1767
|
+
? `<span class="badge badge-success" style="margin-right:auto;">Installed</span>
|
|
1768
|
+
<button class="btn btn-sm btn-danger" data-store-action="uninstall" data-store-id="${escapeHtml(item.id)}">Remove</button>`
|
|
1769
|
+
: `<button class="btn btn-sm btn-primary" data-store-action="install" data-store-id="${escapeHtml(item.id)}">Install</button>`
|
|
1770
|
+
}
|
|
1771
|
+
</div>`;
|
|
1772
|
+
grid.appendChild(card);
|
|
1773
|
+
}
|
|
1774
|
+
section.appendChild(grid);
|
|
1775
|
+
cardsWrap.appendChild(section);
|
|
1776
|
+
}
|
|
1777
|
+
if (!totalShown)
|
|
1778
|
+
cardsWrap.innerHTML =
|
|
1779
|
+
'<div class="empty-state"><p>No matching skills</p></div>';
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
renderStore("");
|
|
1783
|
+
|
|
1784
|
+
searchInp.addEventListener("input", () =>
|
|
1785
|
+
renderStore(searchInp.value.trim().toLowerCase()),
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
cardsWrap.addEventListener("click", async (e) => {
|
|
1789
|
+
const btn = e.target.closest("[data-store-action]");
|
|
1790
|
+
if (!btn) return;
|
|
1791
|
+
const { storeAction, storeId } = btn.dataset;
|
|
1792
|
+
btn.disabled = true;
|
|
1793
|
+
btn.textContent = storeAction === "install" ? "Installing…" : "Removing…";
|
|
1794
|
+
try {
|
|
1795
|
+
if (storeAction === "install") {
|
|
1796
|
+
await api(`/store/${storeId}/install`, { method: "POST" });
|
|
1797
|
+
toast("Skill installed!", "success");
|
|
1798
|
+
} else {
|
|
1799
|
+
await api(`/store/${storeId}/uninstall`, { method: "DELETE" });
|
|
1800
|
+
toast("Skill removed", "info");
|
|
1801
|
+
}
|
|
1802
|
+
await loadSkillStore(); // refresh
|
|
1803
|
+
} catch (err) {
|
|
1804
|
+
toast("Error: " + err.message, "error");
|
|
1805
|
+
btn.disabled = false;
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
} catch (err) {
|
|
1809
|
+
wrap.innerHTML =
|
|
1810
|
+
'<div class="empty-state"><p>Failed to load store</p></div>';
|
|
1811
|
+
console.error(err);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
async function loadSkillsPage() {
|
|
1816
|
+
try {
|
|
1817
|
+
const skills = await api("/skills");
|
|
1818
|
+
const container = $("#skillList");
|
|
1819
|
+
container.innerHTML = "";
|
|
1820
|
+
|
|
1821
|
+
if (skills.length === 0) {
|
|
1822
|
+
container.innerHTML =
|
|
1823
|
+
'<div class="empty-state"><p>No skills installed yet. <a href="#" id="goToStore">Browse the store →</a></p></div>';
|
|
1824
|
+
document.getElementById("goToStore")?.addEventListener("click", (e) => {
|
|
1825
|
+
e.preventDefault();
|
|
1826
|
+
document.querySelector('[data-skills-tab="store"]')?.click();
|
|
1827
|
+
});
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
for (const skill of skills) {
|
|
1832
|
+
const card = document.createElement("div");
|
|
1833
|
+
card.className = "item-card";
|
|
1834
|
+
card.innerHTML = `
|
|
1835
|
+
<div class="item-card-header">
|
|
1836
|
+
<div>
|
|
1837
|
+
<div class="item-card-title">${escapeHtml(skill.name)}</div>
|
|
1838
|
+
<div class="item-card-meta">${escapeHtml(skill.description)}</div>
|
|
1839
|
+
</div>
|
|
1840
|
+
<div class="item-card-actions">
|
|
1841
|
+
<span class="badge ${skill.enabled ? "badge-success" : "badge-neutral"}">${skill.enabled ? "Active" : "Disabled"}</span>
|
|
1842
|
+
<button class="btn btn-sm btn-secondary" data-action="editSkill" data-filename="${escapeHtml(skill.filename)}">Edit</button>
|
|
1843
|
+
<button class="btn btn-sm btn-danger" data-action="deleteSkill" data-filename="${escapeHtml(skill.filename)}">×</button>
|
|
1844
|
+
</div>
|
|
1845
|
+
</div>
|
|
1846
|
+
<div class="item-card-meta">Trigger: ${escapeHtml(skill.trigger || "N/A")} | Category: ${escapeHtml(skill.category)}</div>
|
|
1847
|
+
`;
|
|
1848
|
+
container.appendChild(card);
|
|
1849
|
+
}
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
toast("Failed to load skills", "error");
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
window.editSkill = async (filename) => {
|
|
1856
|
+
try {
|
|
1857
|
+
const data = await api(`/skills/${filename}`);
|
|
1858
|
+
const content = prompt("Edit skill content:", data.content);
|
|
1859
|
+
if (content !== null) {
|
|
1860
|
+
await api(`/skills/${filename}`, { method: "PUT", body: { content } });
|
|
1861
|
+
loadSkillsPage();
|
|
1862
|
+
toast("Skill updated", "success");
|
|
1863
|
+
}
|
|
1864
|
+
} catch (err) {
|
|
1865
|
+
toast("Failed to edit skill", "error");
|
|
1866
|
+
}
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
window.deleteSkill = async (filename) => {
|
|
1870
|
+
if (!confirm(`Delete skill ${filename}?`)) return;
|
|
1871
|
+
try {
|
|
1872
|
+
await api(`/skills/${filename}`, { method: "DELETE" });
|
|
1873
|
+
loadSkillsPage();
|
|
1874
|
+
toast("Skill deleted", "success");
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
toast("Failed to delete", "error");
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
// Skills event delegation
|
|
1881
|
+
$("#skillList").addEventListener("click", (e) => {
|
|
1882
|
+
const btn = e.target.closest("[data-action]");
|
|
1883
|
+
if (!btn) return;
|
|
1884
|
+
const action = btn.dataset.action;
|
|
1885
|
+
if (action === "editSkill") window.editSkill(btn.dataset.filename);
|
|
1886
|
+
else if (action === "deleteSkill") window.deleteSkill(btn.dataset.filename);
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
$("#addSkillBtn").addEventListener("click", () => {
|
|
1890
|
+
const name = prompt("Skill filename (without .md):");
|
|
1891
|
+
if (!name) return;
|
|
1892
|
+
const content = `---\nname: ${name}\ndescription: \ntrigger: \ncategory: general\nenabled: true\n---\n\n# ${name}\n\nDescribe the skill here.`;
|
|
1893
|
+
api("/skills", { method: "POST", body: { filename: name, content } })
|
|
1894
|
+
.then(() => {
|
|
1895
|
+
loadSkillsPage();
|
|
1896
|
+
toast("Skill created", "success");
|
|
1897
|
+
})
|
|
1898
|
+
.catch(() => toast("Failed to create skill", "error"));
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
// ── MCP Servers Page ──
|
|
1902
|
+
|
|
1903
|
+
async function loadMCPPage() {
|
|
1904
|
+
try {
|
|
1905
|
+
const servers = await api("/mcp");
|
|
1906
|
+
const container = $("#mcpServerList");
|
|
1907
|
+
container.innerHTML = "";
|
|
1908
|
+
|
|
1909
|
+
if (servers.length === 0) {
|
|
1910
|
+
container.innerHTML =
|
|
1911
|
+
'<div class="empty-state"><p>No MCP servers configured</p></div>';
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
for (const srv of servers) {
|
|
1916
|
+
const card = document.createElement("div");
|
|
1917
|
+
card.className = "item-card";
|
|
1918
|
+
card.innerHTML = `
|
|
1919
|
+
<div class="item-card-header">
|
|
1920
|
+
<div>
|
|
1921
|
+
<div class="item-card-title">${escapeHtml(srv.name)}</div>
|
|
1922
|
+
<div class="item-card-meta font-mono">${escapeHtml(srv.command)}</div>
|
|
1923
|
+
</div>
|
|
1924
|
+
<div class="item-card-actions">
|
|
1925
|
+
<span class="badge ${srv.status === "running" ? "badge-success" : "badge-neutral"}">${srv.status}</span>
|
|
1926
|
+
${srv.status === "running"
|
|
1927
|
+
? `<button class="btn btn-sm btn-secondary" data-action="stopMCP" data-id="${srv.id}">Stop</button>`
|
|
1928
|
+
: `<button class="btn btn-sm btn-primary" data-action="startMCP" data-id="${srv.id}">Start</button>`
|
|
1929
|
+
}
|
|
1930
|
+
${srv.config?.auth?.type === "oauth" ? `<button class="btn btn-sm btn-primary" data-action="loginMCP" data-id="${srv.id}">Login</button>` : ""}
|
|
1931
|
+
<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>
|
|
1932
|
+
<button class="btn btn-sm btn-danger" data-action="deleteMCP" data-id="${srv.id}">×</button>
|
|
1933
|
+
</div>
|
|
1934
|
+
</div>
|
|
1935
|
+
${srv.toolCount > 0 ? `<div class="item-card-meta">${srv.toolCount} tools available</div>` : ""}
|
|
1936
|
+
`;
|
|
1937
|
+
container.appendChild(card);
|
|
1938
|
+
}
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
toast("Failed to load MCP servers", "error");
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
window.startMCP = async (id) => {
|
|
1945
|
+
try {
|
|
1946
|
+
await api(`/mcp/${id}/start`, { method: "POST" });
|
|
1947
|
+
loadMCPPage();
|
|
1948
|
+
toast("Server started", "success");
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
toast(err.message, "error");
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
|
|
1954
|
+
window.stopMCP = async (id) => {
|
|
1955
|
+
try {
|
|
1956
|
+
await api(`/mcp/${id}/stop`, { method: "POST" });
|
|
1957
|
+
loadMCPPage();
|
|
1958
|
+
toast("Server stopped", "success");
|
|
1959
|
+
} catch (err) {
|
|
1960
|
+
toast(err.message, "error");
|
|
1961
|
+
}
|
|
1962
|
+
};
|
|
1963
|
+
|
|
1964
|
+
window.deleteMCP = async (id) => {
|
|
1965
|
+
if (!confirm("Delete this MCP server?")) return;
|
|
1966
|
+
try {
|
|
1967
|
+
await api(`/mcp/${id}`, { method: "DELETE" });
|
|
1968
|
+
loadMCPPage();
|
|
1969
|
+
toast("Server deleted", "success");
|
|
1970
|
+
} catch (err) {
|
|
1971
|
+
toast("Failed to delete", "error");
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
|
|
1975
|
+
// MCP event delegation
|
|
1976
|
+
$("#mcpServerList").addEventListener("click", (e) => {
|
|
1977
|
+
const btn = e.target.closest("[data-action]");
|
|
1978
|
+
if (!btn) return;
|
|
1979
|
+
const id = btn.dataset.id;
|
|
1980
|
+
const action = btn.dataset.action;
|
|
1981
|
+
if (action === "startMCP") window.startMCP(id);
|
|
1982
|
+
else if (action === "stopMCP") window.stopMCP(id);
|
|
1983
|
+
else if (action === "deleteMCP") window.deleteMCP(id);
|
|
1984
|
+
else if (action === "loginMCP") {
|
|
1985
|
+
const w = window.open(
|
|
1986
|
+
`/api/mcp/${id}/start`,
|
|
1987
|
+
"oauth",
|
|
1988
|
+
"width=600,height=700",
|
|
1989
|
+
);
|
|
1990
|
+
// We expect the server to return 302 or JSON with `{status: 'oauth_redirect', url}` for a normal GET/POST
|
|
1991
|
+
// Let's first make an API call to get the URL, then window.open that URL
|
|
1992
|
+
api(`/mcp/${id}/start`, { method: "POST" })
|
|
1993
|
+
.then((res) => {
|
|
1994
|
+
if (res.status === "oauth_redirect") {
|
|
1995
|
+
window.open(res.url, "oauth", "width=600,height=700");
|
|
1996
|
+
} else {
|
|
1997
|
+
toast("Server started without needing login", "success");
|
|
1998
|
+
loadMCPPage();
|
|
1999
|
+
}
|
|
2000
|
+
})
|
|
2001
|
+
.catch((err) => toast("Login failed: " + err.message, "error"));
|
|
2002
|
+
} else if (action === "editMCP") {
|
|
2003
|
+
$("#mcpModalTitle").textContent = "Edit MCP Server";
|
|
2004
|
+
$("#mcpName").value = btn.dataset.name;
|
|
2005
|
+
$("#mcpUrl").value = btn.dataset.url;
|
|
2006
|
+
$("#mcpModal").dataset.id = id;
|
|
2007
|
+
|
|
2008
|
+
// Auth fields
|
|
2009
|
+
const config = JSON.parse(btn.dataset.config || "{}");
|
|
2010
|
+
const auth = config.auth || {};
|
|
2011
|
+
$("#mcpAuthType").value = auth.type || "none";
|
|
2012
|
+
$("#mcpAuthToken").value = auth.token || "";
|
|
2013
|
+
$("#mcpAuthClientId").value = auth.clientId || "";
|
|
2014
|
+
$("#mcpAuthServerUrl").value = auth.authServerUrl || "";
|
|
2015
|
+
updateMcpAuthFields();
|
|
2016
|
+
|
|
2017
|
+
$("#mcpModal").classList.remove("hidden");
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
function updateMcpAuthFields() {
|
|
2022
|
+
const type = $("#mcpAuthType").value;
|
|
2023
|
+
|
|
2024
|
+
if (type === "bearer") {
|
|
2025
|
+
$("#mcpAuthBearerGroup").classList.remove("hidden");
|
|
2026
|
+
$("#mcpAuthOauthGroup").classList.add("hidden");
|
|
2027
|
+
} else if (type === "oauth") {
|
|
2028
|
+
$("#mcpAuthBearerGroup").classList.add("hidden");
|
|
2029
|
+
$("#mcpAuthOauthGroup").classList.remove("hidden");
|
|
2030
|
+
} else {
|
|
2031
|
+
$("#mcpAuthBearerGroup").classList.add("hidden");
|
|
2032
|
+
$("#mcpAuthOauthGroup").classList.add("hidden");
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
$("#mcpAuthType").addEventListener("change", updateMcpAuthFields);
|
|
2037
|
+
$("#mcpAuthType").addEventListener("input", updateMcpAuthFields);
|
|
2038
|
+
|
|
2039
|
+
$("#addMcpBtn").addEventListener("click", () => {
|
|
2040
|
+
$("#mcpName").value = "";
|
|
2041
|
+
$("#mcpUrl").value = "";
|
|
2042
|
+
$("#mcpAuthType").value = "none";
|
|
2043
|
+
$("#mcpAuthToken").value = "";
|
|
2044
|
+
$("#mcpAuthClientId").value = "";
|
|
2045
|
+
$("#mcpAuthServerUrl").value = "";
|
|
2046
|
+
updateMcpAuthFields();
|
|
2047
|
+
|
|
2048
|
+
$("#mcpModalTitle").textContent = "Add MCP Server";
|
|
2049
|
+
$("#mcpModal").dataset.id = "";
|
|
2050
|
+
$("#mcpModal").classList.remove("hidden");
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
$("#closeMcpModal").addEventListener("click", () =>
|
|
2054
|
+
$("#mcpModal").classList.add("hidden"),
|
|
2055
|
+
);
|
|
2056
|
+
$("#cancelMcpModal").addEventListener("click", () =>
|
|
2057
|
+
$("#mcpModal").classList.add("hidden"),
|
|
2058
|
+
);
|
|
2059
|
+
|
|
2060
|
+
$("#saveMcpBtn").addEventListener("click", () => {
|
|
2061
|
+
const name = $("#mcpName").value.trim();
|
|
2062
|
+
const url = $("#mcpUrl").value.trim();
|
|
2063
|
+
if (!name || !url) {
|
|
2064
|
+
toast("Name and URL are required", "error");
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
const id = $("#mcpModal").dataset.id;
|
|
2069
|
+
const method = id ? "PUT" : "POST";
|
|
2070
|
+
const endpoint = id ? `/mcp/${id}` : "/mcp";
|
|
2071
|
+
|
|
2072
|
+
const authType = $("#mcpAuthType").value;
|
|
2073
|
+
const auth = { type: authType };
|
|
2074
|
+
if (authType === "bearer") auth.token = $("#mcpAuthToken").value.trim();
|
|
2075
|
+
if (authType === "oauth") {
|
|
2076
|
+
auth.clientId = $("#mcpAuthClientId").value.trim();
|
|
2077
|
+
auth.authServerUrl = $("#mcpAuthServerUrl").value.trim();
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
api(endpoint, {
|
|
2081
|
+
method,
|
|
2082
|
+
body: { name, command: url, config: { auth }, enabled: true },
|
|
2083
|
+
})
|
|
2084
|
+
.then(() => {
|
|
2085
|
+
loadMCPPage();
|
|
2086
|
+
$("#mcpModal").classList.add("hidden");
|
|
2087
|
+
toast(id ? "Server updated" : "Server added", "success");
|
|
2088
|
+
})
|
|
2089
|
+
.catch((err) => toast("Failed to save server: " + err.message, "error"));
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
// Listen for popup messages to refresh auth
|
|
2093
|
+
window.addEventListener("message", (e) => {
|
|
2094
|
+
if (e.data?.type === "mcp_oauth_success") {
|
|
2095
|
+
toast("OAuth authentication successful!", "success");
|
|
2096
|
+
loadMCPPage();
|
|
2097
|
+
}
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
// ── Scheduler Page ──
|
|
2101
|
+
|
|
2102
|
+
async function loadSchedulerPage() {
|
|
2103
|
+
try {
|
|
2104
|
+
const tasks = await api("/scheduler");
|
|
2105
|
+
const container = $("#taskList");
|
|
2106
|
+
container.innerHTML = "";
|
|
2107
|
+
|
|
2108
|
+
if (tasks.length === 0) {
|
|
2109
|
+
container.innerHTML =
|
|
2110
|
+
'<div class="empty-state"><p>No scheduled tasks</p></div>';
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
for (const task of tasks) {
|
|
2115
|
+
const card = document.createElement("div");
|
|
2116
|
+
card.className = "item-card";
|
|
2117
|
+
card.innerHTML = `
|
|
2118
|
+
<div class="item-card-header">
|
|
2119
|
+
<div>
|
|
2120
|
+
<div class="item-card-title">${escapeHtml(task.name)}</div>
|
|
2121
|
+
<div class="item-card-meta font-mono">${escapeHtml(task.cronExpression)}</div>
|
|
2122
|
+
</div>
|
|
2123
|
+
<div class="item-card-actions">
|
|
2124
|
+
<span class="badge ${task.enabled ? "badge-success" : "badge-neutral"}">${task.enabled ? "Active" : "Paused"}</span>
|
|
2125
|
+
<button class="btn btn-sm btn-primary" data-action="runTask" data-id="${task.id}">Run Now</button>
|
|
2126
|
+
<button class="btn btn-sm btn-danger" data-action="deleteTask" data-id="${task.id}">×</button>
|
|
2127
|
+
</div>
|
|
2128
|
+
</div>
|
|
2129
|
+
<div class="item-card-meta">${escapeHtml(task.config?.prompt?.slice(0, 100) || "No prompt")}${task.lastRun ? ` | Last run: ${formatTime(task.lastRun)}` : ""}</div>
|
|
2130
|
+
`;
|
|
2131
|
+
container.appendChild(card);
|
|
2132
|
+
}
|
|
2133
|
+
} catch (err) {
|
|
2134
|
+
toast("Failed to load tasks", "error");
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
window.runTask = async (id) => {
|
|
2139
|
+
try {
|
|
2140
|
+
await api(`/scheduler/${id}/run`, { method: "POST" });
|
|
2141
|
+
toast("Task started", "success");
|
|
2142
|
+
} catch (err) {
|
|
2143
|
+
toast(err.message, "error");
|
|
2144
|
+
}
|
|
2145
|
+
};
|
|
2146
|
+
|
|
2147
|
+
window.deleteTask = async (id) => {
|
|
2148
|
+
if (!confirm("Delete this task?")) return;
|
|
2149
|
+
try {
|
|
2150
|
+
await api(`/scheduler/${id}`, { method: "DELETE" });
|
|
2151
|
+
loadSchedulerPage();
|
|
2152
|
+
toast("Task deleted", "success");
|
|
2153
|
+
} catch (err) {
|
|
2154
|
+
toast("Failed to delete", "error");
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
// Scheduler event delegation
|
|
2159
|
+
$("#taskList").addEventListener("click", (e) => {
|
|
2160
|
+
const btn = e.target.closest("[data-action]");
|
|
2161
|
+
if (!btn) return;
|
|
2162
|
+
const id = btn.dataset.id;
|
|
2163
|
+
const action = btn.dataset.action;
|
|
2164
|
+
if (action === "runTask") window.runTask(id);
|
|
2165
|
+
else if (action === "deleteTask") window.deleteTask(id);
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
$("#addTaskBtn").addEventListener("click", () => {
|
|
2169
|
+
const name = prompt("Task name:");
|
|
2170
|
+
if (!name) return;
|
|
2171
|
+
const cronExpression = prompt(
|
|
2172
|
+
"Cron expression (e.g., */30 * * * * for every 30 min):",
|
|
2173
|
+
);
|
|
2174
|
+
if (!cronExpression) return;
|
|
2175
|
+
const promptText = prompt("What should the agent do?");
|
|
2176
|
+
if (!promptText) return;
|
|
2177
|
+
|
|
2178
|
+
api("/scheduler", {
|
|
2179
|
+
method: "POST",
|
|
2180
|
+
body: { name, cronExpression, prompt: promptText },
|
|
2181
|
+
})
|
|
2182
|
+
.then(() => {
|
|
2183
|
+
loadSchedulerPage();
|
|
2184
|
+
toast("Task created", "success");
|
|
2185
|
+
})
|
|
2186
|
+
.catch((err) => toast(err.message, "error"));
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
// ── Messaging Page ──
|
|
2190
|
+
|
|
2191
|
+
// Registry of supported platforms — add new entries here to support more providers
|
|
2192
|
+
const _svgLogo = {
|
|
2193
|
+
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>`,
|
|
2194
|
+
|
|
2195
|
+
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>`,
|
|
2196
|
+
|
|
2197
|
+
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>`,
|
|
2198
|
+
|
|
2199
|
+
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>`,
|
|
2200
|
+
};
|
|
2201
|
+
|
|
2202
|
+
const MESSAGING_PLATFORM_GROUPS = [
|
|
2203
|
+
{
|
|
2204
|
+
id: "text",
|
|
2205
|
+
label: "Text & Chat",
|
|
2206
|
+
description: "Send and receive messages",
|
|
2207
|
+
},
|
|
2208
|
+
{
|
|
2209
|
+
id: "voice",
|
|
2210
|
+
label: "Voice Calls",
|
|
2211
|
+
description: "Inbound & outbound phone calls",
|
|
2212
|
+
},
|
|
2213
|
+
];
|
|
2214
|
+
|
|
2215
|
+
const MESSAGING_PLATFORMS = [
|
|
2216
|
+
{
|
|
2217
|
+
id: "whatsapp",
|
|
2218
|
+
name: "WhatsApp",
|
|
2219
|
+
group: "text",
|
|
2220
|
+
color: "#25D366",
|
|
2221
|
+
connectMethod: "qr",
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
id: "telegram",
|
|
2225
|
+
name: "Telegram",
|
|
2226
|
+
group: "text",
|
|
2227
|
+
color: "#2AABEE",
|
|
2228
|
+
connectMethod: "config",
|
|
2229
|
+
},
|
|
2230
|
+
{
|
|
2231
|
+
id: "discord",
|
|
2232
|
+
name: "Discord",
|
|
2233
|
+
group: "text",
|
|
2234
|
+
color: "#5865F2",
|
|
2235
|
+
connectMethod: "config",
|
|
2236
|
+
},
|
|
2237
|
+
{
|
|
2238
|
+
id: "telnyx",
|
|
2239
|
+
name: "Telnyx Voice",
|
|
2240
|
+
group: "voice",
|
|
2241
|
+
color: "#00C8A0",
|
|
2242
|
+
connectMethod: "config",
|
|
2243
|
+
},
|
|
2244
|
+
];
|
|
2245
|
+
|
|
2246
|
+
// Per-platform whitelist config
|
|
2247
|
+
const PLATFORM_WHITELIST = {
|
|
2248
|
+
whatsapp: {
|
|
2249
|
+
settingKey: "platform_whitelist_whatsapp",
|
|
2250
|
+
label: "Approved contacts",
|
|
2251
|
+
emptyHint:
|
|
2252
|
+
"No approved contacts yet — senders are added via the allow popup.",
|
|
2253
|
+
allowAdd: false,
|
|
2254
|
+
saveFn: async (list) =>
|
|
2255
|
+
api("/settings", {
|
|
2256
|
+
method: "PUT",
|
|
2257
|
+
body: { platform_whitelist_whatsapp: JSON.stringify(list) },
|
|
2258
|
+
}),
|
|
2259
|
+
},
|
|
2260
|
+
telnyx: {
|
|
2261
|
+
settingKey: "platform_whitelist_telnyx",
|
|
2262
|
+
label: "Allowed callers",
|
|
2263
|
+
emptyHint:
|
|
2264
|
+
"Empty — all inbound callers blocked (or gated via secret code if set).",
|
|
2265
|
+
allowAdd: true,
|
|
2266
|
+
addPlaceholder: "e.g. +12125550100",
|
|
2267
|
+
saveFn: async (list) =>
|
|
2268
|
+
api("/messaging/telnyx/whitelist", {
|
|
2269
|
+
method: "PUT",
|
|
2270
|
+
body: { numbers: list },
|
|
2271
|
+
}),
|
|
2272
|
+
},
|
|
2273
|
+
discord: {
|
|
2274
|
+
settingKey: "platform_whitelist_discord",
|
|
2275
|
+
label: "Approved users, servers & channels",
|
|
2276
|
+
emptyHint:
|
|
2277
|
+
"No entries — all messages blocked. Add entries via the allow popup or manually below.",
|
|
2278
|
+
allowAdd: true,
|
|
2279
|
+
addTypes: ["user", "guild", "channel"],
|
|
2280
|
+
saveFn: async (list) =>
|
|
2281
|
+
api("/messaging/discord/whitelist", {
|
|
2282
|
+
method: "PUT",
|
|
2283
|
+
body: { ids: list },
|
|
2284
|
+
}),
|
|
2285
|
+
},
|
|
2286
|
+
telegram: {
|
|
2287
|
+
settingKey: "platform_whitelist_telegram",
|
|
2288
|
+
label: "Approved users & groups",
|
|
2289
|
+
emptyHint:
|
|
2290
|
+
"No entries — all messages blocked. Add entries via the allow popup or manually below.",
|
|
2291
|
+
allowAdd: true,
|
|
2292
|
+
addTypes: ["user", "group"],
|
|
2293
|
+
saveFn: async (list) =>
|
|
2294
|
+
api("/messaging/telegram/whitelist", {
|
|
2295
|
+
method: "PUT",
|
|
2296
|
+
body: { ids: list },
|
|
2297
|
+
}),
|
|
2298
|
+
},
|
|
2299
|
+
};
|
|
2300
|
+
|
|
2301
|
+
async function loadMessagingPage() {
|
|
2302
|
+
try {
|
|
2303
|
+
const [statuses, settings] = await Promise.all([
|
|
2304
|
+
api("/messaging/status"),
|
|
2305
|
+
api("/settings"),
|
|
2306
|
+
]);
|
|
2307
|
+
const container = $("#platformList");
|
|
2308
|
+
container.innerHTML = "";
|
|
2309
|
+
|
|
2310
|
+
for (const group of MESSAGING_PLATFORM_GROUPS) {
|
|
2311
|
+
const groupPlatforms = MESSAGING_PLATFORMS.filter(
|
|
2312
|
+
(p) => p.group === group.id,
|
|
2313
|
+
);
|
|
2314
|
+
|
|
2315
|
+
// Section header
|
|
2316
|
+
const section = document.createElement("div");
|
|
2317
|
+
section.style.cssText = "margin-bottom:28px;";
|
|
2318
|
+
|
|
2319
|
+
const heading = document.createElement("div");
|
|
2320
|
+
heading.style.cssText =
|
|
2321
|
+
"display:flex;align-items:baseline;gap:10px;margin-bottom:14px;padding-bottom:8px;border-bottom:1px solid var(--border);";
|
|
2322
|
+
heading.innerHTML = `
|
|
2323
|
+
<span style="font-size:0.95rem;font-weight:700;">${escapeHtml(group.label)}</span>
|
|
2324
|
+
<span style="font-size:0.78rem;color:var(--text-muted);">${escapeHtml(group.description)}</span>`;
|
|
2325
|
+
section.appendChild(heading);
|
|
2326
|
+
|
|
2327
|
+
// Grid — 2 cols for text/chat, single col for voice
|
|
2328
|
+
const grid = document.createElement("div");
|
|
2329
|
+
grid.style.cssText =
|
|
2330
|
+
group.id === "text"
|
|
2331
|
+
? "display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px;"
|
|
2332
|
+
: "display:flex;flex-direction:column;gap:14px;";
|
|
2333
|
+
|
|
2334
|
+
for (const platform of groupPlatforms) {
|
|
2335
|
+
const info = statuses[platform.id] || { status: "not_configured" };
|
|
2336
|
+
const wlCfg = PLATFORM_WHITELIST[platform.id];
|
|
2337
|
+
const isConnected = info.status === "connected";
|
|
2338
|
+
const isConnecting =
|
|
2339
|
+
info.status === "connecting" || info.status === "awaiting_qr";
|
|
2340
|
+
|
|
2341
|
+
let wlList = [];
|
|
2342
|
+
try {
|
|
2343
|
+
const raw = settings[wlCfg.settingKey];
|
|
2344
|
+
if (raw) {
|
|
2345
|
+
wlList = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
2346
|
+
}
|
|
2347
|
+
if (!Array.isArray(wlList)) wlList = [];
|
|
2348
|
+
} catch {
|
|
2349
|
+
wlList = [];
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Auth subtitle
|
|
2353
|
+
let authSub = "";
|
|
2354
|
+
if (isConnected) {
|
|
2355
|
+
if (info.authInfo?.phoneNumber)
|
|
2356
|
+
authSub = escapeHtml(info.authInfo.phoneNumber);
|
|
2357
|
+
else if (info.authInfo?.tag) authSub = escapeHtml(info.authInfo.tag);
|
|
2358
|
+
else if (info.authInfo?.username)
|
|
2359
|
+
authSub = "@" + escapeHtml(info.authInfo.username);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
const card = document.createElement("div");
|
|
2363
|
+
card.className = "card";
|
|
2364
|
+
card.style.cssText = "margin:0;";
|
|
2365
|
+
|
|
2366
|
+
// ── Top row: logo + name + status + buttons
|
|
2367
|
+
const topRow = document.createElement("div");
|
|
2368
|
+
topRow.className = "flex items-center justify-between";
|
|
2369
|
+
topRow.innerHTML = `
|
|
2370
|
+
<div class="flex items-center gap-3">
|
|
2371
|
+
<div style="width:40px;height:40px;flex-shrink:0;border-radius:10px;overflow:hidden;">${_svgLogo[platform.id] || ""}</div>
|
|
2372
|
+
<div>
|
|
2373
|
+
<div class="item-card-title" style="font-size:0.97rem;">${escapeHtml(platform.name)}</div>
|
|
2374
|
+
<div class="flex items-center gap-2 mt-1" style="flex-wrap:wrap;">
|
|
2375
|
+
<span class="badge ${isConnected ? "badge-success" : "badge-neutral"}" style="font-size:0.7rem;">
|
|
2376
|
+
${escapeHtml(info.status.replace(/_/g, " "))}
|
|
2377
|
+
</span>
|
|
2378
|
+
${authSub ? `<span class="text-xs text-muted">${authSub}</span>` : ""}
|
|
2379
|
+
${!isConnected && info.lastConnected ? `<span class="text-xs text-muted">last seen ${formatTime(info.lastConnected)}</span>` : ""}
|
|
2380
|
+
</div>
|
|
2381
|
+
</div>
|
|
2382
|
+
</div>
|
|
2383
|
+
<div class="flex gap-2" style="flex-shrink:0;">
|
|
2384
|
+
${isConnected
|
|
2385
|
+
? `<button class="btn btn-sm btn-secondary" data-action="disconnectPlatform" data-platform="${platform.id}">Disconnect</button>
|
|
2386
|
+
<button class="btn btn-sm btn-danger" data-action="logoutPlatform" data-platform="${platform.id}">Logout</button>`
|
|
2387
|
+
: isConnecting
|
|
2388
|
+
? `<span class="text-muted text-sm" style="padding:0 4px;">Connecting…</span>`
|
|
2389
|
+
: `<button class="btn btn-sm btn-primary" data-action="connectPlatform" data-platform="${platform.id}" data-method="${platform.connectMethod}">Connect</button>`
|
|
2390
|
+
}
|
|
2391
|
+
</div>`;
|
|
2392
|
+
card.appendChild(topRow);
|
|
2393
|
+
|
|
2394
|
+
// ── Whitelist collapsible strip
|
|
2395
|
+
const strip = document.createElement("div");
|
|
2396
|
+
strip.style.cssText =
|
|
2397
|
+
"border-top:1px solid var(--border);margin:14px -20px 0;";
|
|
2398
|
+
|
|
2399
|
+
const arrowId = `wl-arrow-${platform.id}`;
|
|
2400
|
+
const labelId = `wl-label-${platform.id}`;
|
|
2401
|
+
const toggleBtn = document.createElement("button");
|
|
2402
|
+
toggleBtn.style.cssText =
|
|
2403
|
+
"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;";
|
|
2404
|
+
toggleBtn.innerHTML = `<span id="${arrowId}" style="font-size:0.65rem;transition:transform 0.15s;display:inline-block;">▶</span>
|
|
2405
|
+
<span id="${labelId}">${_wlLabel(wlCfg.label, wlList.length)}</span>`;
|
|
2406
|
+
|
|
2407
|
+
const panel = document.createElement("div");
|
|
2408
|
+
panel.id = `wl-panel-${platform.id}`;
|
|
2409
|
+
panel.style.cssText = "display:none;padding:4px 20px 14px;";
|
|
2410
|
+
_buildWhitelistPanel(panel, wlList, wlCfg, platform.id);
|
|
2411
|
+
|
|
2412
|
+
toggleBtn.addEventListener("click", () => {
|
|
2413
|
+
const open = panel.style.display !== "none";
|
|
2414
|
+
panel.style.display = open ? "none" : "block";
|
|
2415
|
+
document.getElementById(arrowId).style.transform = open
|
|
2416
|
+
? ""
|
|
2417
|
+
: "rotate(90deg)";
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
strip.appendChild(toggleBtn);
|
|
2421
|
+
strip.appendChild(panel);
|
|
2422
|
+
card.appendChild(strip);
|
|
2423
|
+
|
|
2424
|
+
// ── Telnyx-only: voice secret code ─────────────────────────────────
|
|
2425
|
+
if (platform.id === "telnyx") {
|
|
2426
|
+
const secretStrip = document.createElement("div");
|
|
2427
|
+
secretStrip.style.cssText =
|
|
2428
|
+
"border-top:1px solid var(--border);margin:0 -20px;";
|
|
2429
|
+
|
|
2430
|
+
const secretArrowId = `secret-arrow-telnyx`;
|
|
2431
|
+
const secretToggle = document.createElement("button");
|
|
2432
|
+
secretToggle.style.cssText =
|
|
2433
|
+
"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;";
|
|
2434
|
+
secretToggle.innerHTML = `<span id="${secretArrowId}" style="font-size:0.65rem;transition:transform 0.15s;display:inline-block;">▶</span>
|
|
2435
|
+
<span>Voice secret code</span>`;
|
|
2436
|
+
|
|
2437
|
+
const secretPanel = document.createElement("div");
|
|
2438
|
+
secretPanel.style.cssText = "display:none;padding:4px 20px 14px;";
|
|
2439
|
+
|
|
2440
|
+
const currentSecret = settings["platform_voice_secret_telnyx"] || "";
|
|
2441
|
+
secretPanel.innerHTML = `
|
|
2442
|
+
<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>
|
|
2443
|
+
<div style="display:flex;gap:8px;align-items:center;">
|
|
2444
|
+
<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"/>
|
|
2445
|
+
<button id="telnyx-secret-save" class="btn btn-primary btn-sm">Save</button>
|
|
2446
|
+
<button id="telnyx-secret-clear" class="btn btn-sm btn-secondary">Clear</button>
|
|
2447
|
+
</div>`;
|
|
2448
|
+
|
|
2449
|
+
secretToggle.addEventListener("click", () => {
|
|
2450
|
+
const open = secretPanel.style.display !== "none";
|
|
2451
|
+
secretPanel.style.display = open ? "none" : "block";
|
|
2452
|
+
document.getElementById(secretArrowId).style.transform = open
|
|
2453
|
+
? ""
|
|
2454
|
+
: "rotate(90deg)";
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
secretPanel.addEventListener("click", async (e) => {
|
|
2458
|
+
if (e.target.id === "telnyx-secret-save") {
|
|
2459
|
+
const val = document.getElementById("telnyx-secret-input").value;
|
|
2460
|
+
try {
|
|
2461
|
+
await api("/messaging/telnyx/voice-secret", {
|
|
2462
|
+
method: "PUT",
|
|
2463
|
+
body: { secret: val },
|
|
2464
|
+
});
|
|
2465
|
+
toast("Secret code saved", "success");
|
|
2466
|
+
} catch {
|
|
2467
|
+
toast("Failed to save secret", "error");
|
|
2468
|
+
}
|
|
2469
|
+
} else if (e.target.id === "telnyx-secret-clear") {
|
|
2470
|
+
document.getElementById("telnyx-secret-input").value = "";
|
|
2471
|
+
try {
|
|
2472
|
+
await api("/messaging/telnyx/voice-secret", {
|
|
2473
|
+
method: "PUT",
|
|
2474
|
+
body: { secret: "" },
|
|
2475
|
+
});
|
|
2476
|
+
toast("Secret code cleared", "success");
|
|
2477
|
+
} catch {
|
|
2478
|
+
toast("Failed to clear secret", "error");
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
secretStrip.appendChild(secretToggle);
|
|
2484
|
+
secretStrip.appendChild(secretPanel);
|
|
2485
|
+
card.appendChild(secretStrip);
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
grid.appendChild(card);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
section.appendChild(grid);
|
|
2492
|
+
container.appendChild(section);
|
|
2493
|
+
}
|
|
2494
|
+
} catch (err) {
|
|
2495
|
+
console.error(err);
|
|
2496
|
+
toast("Failed to load messaging", "error");
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
function _wlLabel(label, count) {
|
|
2501
|
+
return count
|
|
2502
|
+
? `${label} <strong style="color:var(--text);font-weight:600;">(${count})</strong>`
|
|
2503
|
+
: `${label} <span style="opacity:0.55;">— none</span>`;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
function _buildWhitelistPanel(panel, list, wlCfg, platformId) {
|
|
2507
|
+
panel.innerHTML = "";
|
|
2508
|
+
|
|
2509
|
+
// Type-badge colours for Discord prefixed entries
|
|
2510
|
+
const TYPE_COLORS = {
|
|
2511
|
+
user: "#5865F2",
|
|
2512
|
+
guild: "#57F287",
|
|
2513
|
+
channel: "#FEE75C",
|
|
2514
|
+
group: "#2AABEE",
|
|
2515
|
+
};
|
|
2516
|
+
const TYPE_LABELS = {
|
|
2517
|
+
user: "User",
|
|
2518
|
+
guild: "Server",
|
|
2519
|
+
channel: "Channel",
|
|
2520
|
+
group: "Group",
|
|
2521
|
+
};
|
|
2522
|
+
|
|
2523
|
+
if (!list.length) {
|
|
2524
|
+
const empty = document.createElement("p");
|
|
2525
|
+
empty.className = "text-xs text-muted";
|
|
2526
|
+
empty.style.margin = "0 0 6px";
|
|
2527
|
+
empty.textContent = wlCfg.emptyHint;
|
|
2528
|
+
panel.appendChild(empty);
|
|
2529
|
+
} else {
|
|
2530
|
+
const tags = document.createElement("div");
|
|
2531
|
+
tags.style.cssText =
|
|
2532
|
+
"display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;";
|
|
2533
|
+
for (const entry of list) {
|
|
2534
|
+
// Parse optional prefix
|
|
2535
|
+
const colon = entry.indexOf(":");
|
|
2536
|
+
const entryType =
|
|
2537
|
+
colon > 0 &&
|
|
2538
|
+
["user", "guild", "channel"].includes(entry.slice(0, colon))
|
|
2539
|
+
? entry.slice(0, colon)
|
|
2540
|
+
: null;
|
|
2541
|
+
const entryId = colon > 0 ? entry.slice(colon + 1) : entry;
|
|
2542
|
+
|
|
2543
|
+
const tag = document.createElement("span");
|
|
2544
|
+
tag.style.cssText =
|
|
2545
|
+
"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;";
|
|
2546
|
+
|
|
2547
|
+
if (entryType) {
|
|
2548
|
+
const badge = document.createElement("span");
|
|
2549
|
+
badge.style.cssText = `background:${TYPE_COLORS[entryType] || "#888"};color:#000;border-radius:999px;padding:1px 7px;font-size:0.71rem;font-weight:600;`;
|
|
2550
|
+
badge.textContent = TYPE_LABELS[entryType] || entryType;
|
|
2551
|
+
tag.appendChild(badge);
|
|
2552
|
+
tag.appendChild(document.createTextNode(" " + entryId));
|
|
2553
|
+
} else {
|
|
2554
|
+
tag.appendChild(document.createTextNode(entry));
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const removeBtn = document.createElement("button");
|
|
2558
|
+
removeBtn.style.cssText =
|
|
2559
|
+
"background:none;border:none;cursor:pointer;color:var(--text-muted);padding:0;font-size:1rem;line-height:1;margin-left:2px;";
|
|
2560
|
+
removeBtn.textContent = "×";
|
|
2561
|
+
removeBtn.title = "Remove";
|
|
2562
|
+
removeBtn.addEventListener("click", async () => {
|
|
2563
|
+
const newList = list.filter((n) => n !== entry);
|
|
2564
|
+
try {
|
|
2565
|
+
await wlCfg.saveFn(newList);
|
|
2566
|
+
list = newList;
|
|
2567
|
+
_buildWhitelistPanel(panel, list, wlCfg, platformId);
|
|
2568
|
+
const lbl = document.getElementById(`wl-label-${platformId}`);
|
|
2569
|
+
if (lbl) lbl.innerHTML = _wlLabel(wlCfg.label, newList.length);
|
|
2570
|
+
} catch {
|
|
2571
|
+
toast("Failed to remove", "error");
|
|
2572
|
+
}
|
|
2573
|
+
});
|
|
2574
|
+
tag.appendChild(removeBtn);
|
|
2575
|
+
tags.appendChild(tag);
|
|
2576
|
+
}
|
|
2577
|
+
panel.appendChild(tags);
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
if (wlCfg.allowAdd) {
|
|
2581
|
+
const row = document.createElement("div");
|
|
2582
|
+
row.style.cssText = "display:flex;gap:8px;align-items:center;";
|
|
2583
|
+
|
|
2584
|
+
if (wlCfg.addTypes) {
|
|
2585
|
+
// Type selector + ID input for Discord
|
|
2586
|
+
const sel = document.createElement("select");
|
|
2587
|
+
sel.className = "input";
|
|
2588
|
+
sel.style.cssText = "flex:0 0 auto;width:110px;";
|
|
2589
|
+
for (const t of wlCfg.addTypes) {
|
|
2590
|
+
const opt = document.createElement("option");
|
|
2591
|
+
opt.value = t;
|
|
2592
|
+
opt.textContent = TYPE_LABELS[t] || t;
|
|
2593
|
+
sel.appendChild(opt);
|
|
2594
|
+
}
|
|
2595
|
+
const inp = document.createElement("input");
|
|
2596
|
+
inp.type = "text";
|
|
2597
|
+
inp.className = "input";
|
|
2598
|
+
inp.style.flex = "1";
|
|
2599
|
+
inp.placeholder = "Snowflake ID";
|
|
2600
|
+
const addBtn = document.createElement("button");
|
|
2601
|
+
addBtn.className = "btn btn-primary btn-sm";
|
|
2602
|
+
addBtn.textContent = "Add";
|
|
2603
|
+
addBtn.addEventListener("click", async () => {
|
|
2604
|
+
const id = inp.value.replace(/[^0-9]/g, "").trim();
|
|
2605
|
+
if (!id) return;
|
|
2606
|
+
const val = `${sel.value}:${id}`;
|
|
2607
|
+
if (list.includes(val)) {
|
|
2608
|
+
toast("Already in list", "info");
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const newList = [...list, val];
|
|
2612
|
+
try {
|
|
2613
|
+
await wlCfg.saveFn(newList);
|
|
2614
|
+
list = newList;
|
|
2615
|
+
inp.value = "";
|
|
2616
|
+
_buildWhitelistPanel(panel, list, wlCfg, platformId);
|
|
2617
|
+
const lbl = document.getElementById(`wl-label-${platformId}`);
|
|
2618
|
+
if (lbl) lbl.innerHTML = _wlLabel(wlCfg.label, newList.length);
|
|
2619
|
+
} catch {
|
|
2620
|
+
toast("Failed to add", "error");
|
|
2621
|
+
}
|
|
2622
|
+
});
|
|
2623
|
+
inp.addEventListener("keydown", (e) => {
|
|
2624
|
+
if (e.key === "Enter") addBtn.click();
|
|
2625
|
+
});
|
|
2626
|
+
row.appendChild(sel);
|
|
2627
|
+
row.appendChild(inp);
|
|
2628
|
+
row.appendChild(addBtn);
|
|
2629
|
+
} else {
|
|
2630
|
+
// Plain input for telnyx numbers
|
|
2631
|
+
const inp = document.createElement("input");
|
|
2632
|
+
inp.type = "text";
|
|
2633
|
+
inp.className = "input";
|
|
2634
|
+
inp.style.flex = "1";
|
|
2635
|
+
inp.placeholder = wlCfg.addPlaceholder || "+12125550100";
|
|
2636
|
+
const addBtn = document.createElement("button");
|
|
2637
|
+
addBtn.className = "btn btn-primary btn-sm";
|
|
2638
|
+
addBtn.textContent = "Add";
|
|
2639
|
+
addBtn.addEventListener("click", async () => {
|
|
2640
|
+
const val = inp.value.replace(/[^0-9+]/g, "").trim();
|
|
2641
|
+
if (!val) return;
|
|
2642
|
+
if (list.includes(val)) {
|
|
2643
|
+
toast("Already in list", "info");
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
const newList = [...list, val];
|
|
2647
|
+
try {
|
|
2648
|
+
await wlCfg.saveFn(newList);
|
|
2649
|
+
list = newList;
|
|
2650
|
+
inp.value = "";
|
|
2651
|
+
_buildWhitelistPanel(panel, list, wlCfg, platformId);
|
|
2652
|
+
const lbl = document.getElementById(`wl-label-${platformId}`);
|
|
2653
|
+
if (lbl) lbl.innerHTML = _wlLabel(wlCfg.label, newList.length);
|
|
2654
|
+
} catch {
|
|
2655
|
+
toast("Failed to add", "error");
|
|
2656
|
+
}
|
|
2657
|
+
});
|
|
2658
|
+
inp.addEventListener("keydown", (e) => {
|
|
2659
|
+
if (e.key === "Enter") addBtn.click();
|
|
2660
|
+
});
|
|
2661
|
+
row.appendChild(inp);
|
|
2662
|
+
row.appendChild(addBtn);
|
|
2663
|
+
}
|
|
2664
|
+
panel.appendChild(row);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
async function loadWhitelistUI() {
|
|
2669
|
+
/* replaced — whitelist is now inline in each platform card */
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// Platform action delegation
|
|
2673
|
+
$("#platformList").addEventListener("click", async (e) => {
|
|
2674
|
+
const btn = e.target.closest("[data-action]");
|
|
2675
|
+
if (!btn) return;
|
|
2676
|
+
const { action, platform, method } = btn.dataset;
|
|
2677
|
+
|
|
2678
|
+
if (action === "connectPlatform") {
|
|
2679
|
+
if (method === "config") {
|
|
2680
|
+
if (platform === "telnyx") openTelnyxConfigModal();
|
|
2681
|
+
if (platform === "discord") openDiscordConfigModal();
|
|
2682
|
+
if (platform === "telegram") openTelegramConfigModal();
|
|
2683
|
+
} else {
|
|
2684
|
+
socket.emit("messaging:connect", { platform });
|
|
2685
|
+
toast(`Connecting to ${platform}…`, "info");
|
|
2686
|
+
}
|
|
2687
|
+
} else if (action === "disconnectPlatform") {
|
|
2688
|
+
try {
|
|
2689
|
+
await api("/messaging/disconnect", {
|
|
2690
|
+
method: "POST",
|
|
2691
|
+
body: { platform },
|
|
2692
|
+
});
|
|
2693
|
+
loadMessagingPage();
|
|
2694
|
+
toast(`${platform} disconnected`, "success");
|
|
2695
|
+
} catch (err) {
|
|
2696
|
+
toast(err.message, "error");
|
|
2697
|
+
}
|
|
2698
|
+
} else if (action === "logoutPlatform") {
|
|
2699
|
+
try {
|
|
2700
|
+
await api("/messaging/logout", { method: "POST", body: { platform } });
|
|
2701
|
+
loadMessagingPage();
|
|
2702
|
+
toast(`${platform} logged out`, "success");
|
|
2703
|
+
} catch (err) {
|
|
2704
|
+
toast(err.message, "error");
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
});
|
|
2708
|
+
|
|
2709
|
+
$("#cancelQR").addEventListener("click", () => {
|
|
2710
|
+
$("#messagingQR").classList.add("hidden");
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
// ── Telnyx Config Modal ──────────────────────────────────────────────────────
|
|
2714
|
+
|
|
2715
|
+
async function openTelnyxConfigModal() {
|
|
2716
|
+
// Pre-fill from saved DB config if available
|
|
2717
|
+
let saved = {};
|
|
2718
|
+
try {
|
|
2719
|
+
const st = await api("/messaging/status/telnyx");
|
|
2720
|
+
// Config is not exposed in status; try settings instead
|
|
2721
|
+
} catch { }
|
|
2722
|
+
try {
|
|
2723
|
+
const s = await api("/settings");
|
|
2724
|
+
if (s.telnyx_config)
|
|
2725
|
+
saved =
|
|
2726
|
+
typeof s.telnyx_config === "string"
|
|
2727
|
+
? JSON.parse(s.telnyx_config)
|
|
2728
|
+
: s.telnyx_config;
|
|
2729
|
+
} catch { }
|
|
2730
|
+
|
|
2731
|
+
const TTS_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
|
2732
|
+
const TTS_MODELS = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"];
|
|
2733
|
+
const STT_MODELS = ["whisper-1", "gpt-4o-transcribe"];
|
|
2734
|
+
|
|
2735
|
+
const overlay = document.createElement("div");
|
|
2736
|
+
overlay.style.cssText =
|
|
2737
|
+
"position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;padding:16px;";
|
|
2738
|
+
|
|
2739
|
+
overlay.innerHTML = `
|
|
2740
|
+
<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;">
|
|
2741
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
|
2742
|
+
<div style="font-size:1.15rem;font-weight:700;">📞 Telnyx Voice — Configuration</div>
|
|
2743
|
+
<button id="telnyxModalClose" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--text-muted);">×</button>
|
|
2744
|
+
</div>
|
|
2745
|
+
<div style="display:flex;flex-direction:column;gap:14px;">
|
|
2746
|
+
<div>
|
|
2747
|
+
<label class="label" style="display:block;margin-bottom:4px;">Telnyx API Key *</label>
|
|
2748
|
+
<input id="telnyx_apiKey" class="input" type="password" placeholder="KEY0..." value="${escapeHtml(saved.apiKey || "")}" autocomplete="off"/>
|
|
2749
|
+
</div>
|
|
2750
|
+
<div>
|
|
2751
|
+
<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>
|
|
2752
|
+
<input id="telnyx_phoneNumber" class="input" type="text" placeholder="+12125550100" value="${escapeHtml(saved.phoneNumber || "")}"/>
|
|
2753
|
+
</div>
|
|
2754
|
+
<div>
|
|
2755
|
+
<label class="label" style="display:block;margin-bottom:4px;">Call Control Application ID (Connection ID) *</label>
|
|
2756
|
+
<input id="telnyx_connectionId" class="input" type="text" placeholder="..." value="${escapeHtml(saved.connectionId || "")}"/>
|
|
2757
|
+
</div>
|
|
2758
|
+
<div>
|
|
2759
|
+
<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>
|
|
2760
|
+
<input id="telnyx_webhookUrl" class="input" type="text" placeholder="https://xyz.ngrok.io" value="${escapeHtml(saved.webhookUrl || "")}"/>
|
|
2761
|
+
<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;"><URL>/api/telnyx/webhook</code></div>
|
|
2762
|
+
</div>
|
|
2763
|
+
<div style="display:flex;gap:12px;">
|
|
2764
|
+
<div style="flex:1;">
|
|
2765
|
+
<label class="label" style="display:block;margin-bottom:4px;">TTS Voice</label>
|
|
2766
|
+
<select id="telnyx_ttsVoice" class="input" style="width:100%;">
|
|
2767
|
+
${TTS_VOICES.map((v) => `<option value="${v}"${(saved.ttsVoice || "alloy") === v ? " selected" : ""}>${v}</option>`).join("")}
|
|
2768
|
+
</select>
|
|
2769
|
+
</div>
|
|
2770
|
+
<div style="flex:1;">
|
|
2771
|
+
<label class="label" style="display:block;margin-bottom:4px;">TTS Model</label>
|
|
2772
|
+
<select id="telnyx_ttsModel" class="input" style="width:100%;">
|
|
2773
|
+
${TTS_MODELS.map((m) => `<option value="${m}"${(saved.ttsModel || "tts-1") === m ? " selected" : ""}>${m}</option>`).join("")}
|
|
2774
|
+
</select>
|
|
2775
|
+
</div>
|
|
2776
|
+
</div>
|
|
2777
|
+
<div>
|
|
2778
|
+
<label class="label" style="display:block;margin-bottom:4px;">STT Model</label>
|
|
2779
|
+
<select id="telnyx_sttModel" class="input" style="width:100%;">
|
|
2780
|
+
${STT_MODELS.map((m) => `<option value="${m}"${(saved.sttModel || "whisper-1") === m ? " selected" : ""}>${m}</option>`).join("")}
|
|
2781
|
+
</select>
|
|
2782
|
+
<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>
|
|
2783
|
+
</div>
|
|
2784
|
+
</div>
|
|
2785
|
+
<div style="display:flex;gap:10px;margin-top:22px;justify-content:flex-end;">
|
|
2786
|
+
<button id="telnyxModalCancel" class="btn btn-secondary">Cancel</button>
|
|
2787
|
+
<button id="telnyxModalSave" class="btn btn-primary">Connect</button>
|
|
2788
|
+
</div>
|
|
2789
|
+
</div>`;
|
|
2790
|
+
|
|
2791
|
+
document.body.appendChild(overlay);
|
|
2792
|
+
|
|
2793
|
+
const close = () => overlay.remove();
|
|
2794
|
+
overlay.querySelector("#telnyxModalClose").addEventListener("click", close);
|
|
2795
|
+
overlay.querySelector("#telnyxModalCancel").addEventListener("click", close);
|
|
2796
|
+
overlay.addEventListener("click", (e) => {
|
|
2797
|
+
if (e.target === overlay) close();
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
overlay
|
|
2801
|
+
.querySelector("#telnyxModalSave")
|
|
2802
|
+
.addEventListener("click", async () => {
|
|
2803
|
+
const config = {
|
|
2804
|
+
apiKey: overlay.querySelector("#telnyx_apiKey").value.trim(),
|
|
2805
|
+
phoneNumber: overlay.querySelector("#telnyx_phoneNumber").value.trim(),
|
|
2806
|
+
connectionId: overlay
|
|
2807
|
+
.querySelector("#telnyx_connectionId")
|
|
2808
|
+
.value.trim(),
|
|
2809
|
+
webhookUrl: overlay.querySelector("#telnyx_webhookUrl").value.trim(),
|
|
2810
|
+
ttsVoice: overlay.querySelector("#telnyx_ttsVoice").value,
|
|
2811
|
+
ttsModel: overlay.querySelector("#telnyx_ttsModel").value,
|
|
2812
|
+
sttModel: overlay.querySelector("#telnyx_sttModel").value,
|
|
2813
|
+
};
|
|
2814
|
+
if (
|
|
2815
|
+
!config.apiKey ||
|
|
2816
|
+
!config.phoneNumber ||
|
|
2817
|
+
!config.connectionId ||
|
|
2818
|
+
!config.webhookUrl
|
|
2819
|
+
) {
|
|
2820
|
+
toast("Please fill in all required fields", "error");
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
2823
|
+
try {
|
|
2824
|
+
// Save config snapshot for pre-fill
|
|
2825
|
+
await api("/settings", {
|
|
2826
|
+
method: "PUT",
|
|
2827
|
+
body: { telnyx_config: JSON.stringify(config) },
|
|
2828
|
+
});
|
|
2829
|
+
await api("/messaging/connect", {
|
|
2830
|
+
method: "POST",
|
|
2831
|
+
body: { platform: "telnyx", config },
|
|
2832
|
+
});
|
|
2833
|
+
toast("Telnyx Voice connecting…", "success");
|
|
2834
|
+
close();
|
|
2835
|
+
setTimeout(loadMessagingPage, 1000);
|
|
2836
|
+
} catch (err) {
|
|
2837
|
+
toast("Failed to connect: " + (err.message || err), "error");
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
// ── Discord Config Modal ─────────────────────────────────────────────────────
|
|
2843
|
+
|
|
2844
|
+
async function openDiscordConfigModal() {
|
|
2845
|
+
let saved = {};
|
|
2846
|
+
try {
|
|
2847
|
+
const s = await api("/settings");
|
|
2848
|
+
if (s.discord_config)
|
|
2849
|
+
saved =
|
|
2850
|
+
typeof s.discord_config === "string"
|
|
2851
|
+
? JSON.parse(s.discord_config)
|
|
2852
|
+
: s.discord_config;
|
|
2853
|
+
} catch { }
|
|
2854
|
+
|
|
2855
|
+
const overlay = document.createElement("div");
|
|
2856
|
+
overlay.style.cssText =
|
|
2857
|
+
"position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;padding:16px;";
|
|
2858
|
+
|
|
2859
|
+
overlay.innerHTML = `
|
|
2860
|
+
<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;">
|
|
2861
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
|
2862
|
+
<div style="font-size:1.15rem;font-weight:700;">🎮 Discord — Configuration</div>
|
|
2863
|
+
<button id="discordModalClose" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--text-muted);">×</button>
|
|
2864
|
+
</div>
|
|
2865
|
+
<div style="display:flex;flex-direction:column;gap:14px;">
|
|
2866
|
+
<div>
|
|
2867
|
+
<label class="label" style="display:block;margin-bottom:4px;">Bot Token *</label>
|
|
2868
|
+
<input id="discord_token" class="input" type="password" placeholder="MTxxxxxxxx..." value="${escapeHtml(saved.token || "")}" autocomplete="off"/>
|
|
2869
|
+
<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>
|
|
2870
|
+
</div>
|
|
2871
|
+
</div>
|
|
2872
|
+
<div style="display:flex;gap:10px;margin-top:22px;justify-content:flex-end;">
|
|
2873
|
+
<button id="discordModalCancel" class="btn btn-secondary">Cancel</button>
|
|
2874
|
+
<button id="discordModalSave" class="btn btn-primary">Connect</button>
|
|
2875
|
+
</div>
|
|
2876
|
+
</div>`;
|
|
2877
|
+
|
|
2878
|
+
document.body.appendChild(overlay);
|
|
2879
|
+
|
|
2880
|
+
const close = () => overlay.remove();
|
|
2881
|
+
overlay.querySelector("#discordModalClose").addEventListener("click", close);
|
|
2882
|
+
overlay.querySelector("#discordModalCancel").addEventListener("click", close);
|
|
2883
|
+
overlay.addEventListener("click", (e) => {
|
|
2884
|
+
if (e.target === overlay) close();
|
|
2885
|
+
});
|
|
2886
|
+
|
|
2887
|
+
overlay
|
|
2888
|
+
.querySelector("#discordModalSave")
|
|
2889
|
+
.addEventListener("click", async () => {
|
|
2890
|
+
const config = {
|
|
2891
|
+
token: overlay.querySelector("#discord_token").value.trim(),
|
|
2892
|
+
};
|
|
2893
|
+
if (!config.token) {
|
|
2894
|
+
toast("Bot token is required", "error");
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
try {
|
|
2898
|
+
await api("/settings", {
|
|
2899
|
+
method: "PUT",
|
|
2900
|
+
body: { discord_config: JSON.stringify(config) },
|
|
2901
|
+
});
|
|
2902
|
+
await api("/messaging/connect", {
|
|
2903
|
+
method: "POST",
|
|
2904
|
+
body: { platform: "discord", config },
|
|
2905
|
+
});
|
|
2906
|
+
toast("Discord connecting…", "success");
|
|
2907
|
+
close();
|
|
2908
|
+
setTimeout(loadMessagingPage, 1500);
|
|
2909
|
+
} catch (err) {
|
|
2910
|
+
toast("Failed to connect: " + (err.message || err), "error");
|
|
2911
|
+
}
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// ── Telegram Config Modal ─────────────────────────────────────────────
|
|
2916
|
+
|
|
2917
|
+
async function openTelegramConfigModal() {
|
|
2918
|
+
let saved = {};
|
|
2919
|
+
try {
|
|
2920
|
+
const s = await api("/settings");
|
|
2921
|
+
if (s.telegram_config)
|
|
2922
|
+
saved =
|
|
2923
|
+
typeof s.telegram_config === "string"
|
|
2924
|
+
? JSON.parse(s.telegram_config)
|
|
2925
|
+
: s.telegram_config;
|
|
2926
|
+
} catch { }
|
|
2927
|
+
|
|
2928
|
+
const overlay = document.createElement("div");
|
|
2929
|
+
overlay.style.cssText =
|
|
2930
|
+
"position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;padding:16px;";
|
|
2931
|
+
|
|
2932
|
+
overlay.innerHTML = `
|
|
2933
|
+
<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;">
|
|
2934
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
|
2935
|
+
<div style="font-size:1.15rem;font-weight:700;">✈️ Telegram — Configuration</div>
|
|
2936
|
+
<button id="telegramModalClose" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--text-muted);">×</button>
|
|
2937
|
+
</div>
|
|
2938
|
+
<div style="display:flex;flex-direction:column;gap:14px;">
|
|
2939
|
+
<div>
|
|
2940
|
+
<label class="label" style="display:block;margin-bottom:4px;">Bot Token *</label>
|
|
2941
|
+
<input id="telegram_token" class="input" type="password" placeholder="123456:ABCdef..." value="${escapeHtml(saved.botToken || "")}" autocomplete="off"/>
|
|
2942
|
+
<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>
|
|
2943
|
+
</div>
|
|
2944
|
+
</div>
|
|
2945
|
+
<div style="display:flex;gap:10px;margin-top:22px;justify-content:flex-end;">
|
|
2946
|
+
<button id="telegramModalCancel" class="btn btn-secondary">Cancel</button>
|
|
2947
|
+
<button id="telegramModalSave" class="btn btn-primary">Connect</button>
|
|
2948
|
+
</div>
|
|
2949
|
+
</div>`;
|
|
2950
|
+
|
|
2951
|
+
document.body.appendChild(overlay);
|
|
2952
|
+
|
|
2953
|
+
const close = () => overlay.remove();
|
|
2954
|
+
overlay.querySelector("#telegramModalClose").addEventListener("click", close);
|
|
2955
|
+
overlay
|
|
2956
|
+
.querySelector("#telegramModalCancel")
|
|
2957
|
+
.addEventListener("click", close);
|
|
2958
|
+
overlay.addEventListener("click", (e) => {
|
|
2959
|
+
if (e.target === overlay) close();
|
|
2960
|
+
});
|
|
2961
|
+
|
|
2962
|
+
overlay
|
|
2963
|
+
.querySelector("#telegramModalSave")
|
|
2964
|
+
.addEventListener("click", async () => {
|
|
2965
|
+
const config = {
|
|
2966
|
+
botToken: overlay.querySelector("#telegram_token").value.trim(),
|
|
2967
|
+
};
|
|
2968
|
+
if (!config.botToken) {
|
|
2969
|
+
toast("Bot token is required", "error");
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
try {
|
|
2973
|
+
await api("/settings", {
|
|
2974
|
+
method: "PUT",
|
|
2975
|
+
body: { telegram_config: JSON.stringify(config) },
|
|
2976
|
+
});
|
|
2977
|
+
await api("/messaging/connect", {
|
|
2978
|
+
method: "POST",
|
|
2979
|
+
body: { platform: "telegram", config },
|
|
2980
|
+
});
|
|
2981
|
+
toast("Telegram connecting…", "success");
|
|
2982
|
+
close();
|
|
2983
|
+
setTimeout(loadMessagingPage, 1500);
|
|
2984
|
+
} catch (err) {
|
|
2985
|
+
toast("Failed to connect: " + (err.message || err), "error");
|
|
2986
|
+
}
|
|
2987
|
+
});
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
socket.on("messaging:qr", (data) => {
|
|
2991
|
+
$("#messagingQR").classList.remove("hidden");
|
|
2992
|
+
const container = $("#qrContainer");
|
|
2993
|
+
container.innerHTML = `<img src="https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(data.qr)}&size=280x280" alt="QR Code">`;
|
|
2994
|
+
});
|
|
2995
|
+
|
|
2996
|
+
socket.on("messaging:connected", (data) => {
|
|
2997
|
+
$("#messagingQR").classList.add("hidden");
|
|
2998
|
+
toast(`${data.platform} connected!`, "success");
|
|
2999
|
+
loadMessagingPage();
|
|
3000
|
+
});
|
|
3001
|
+
|
|
3002
|
+
socket.on("messaging:sent", (data) => {
|
|
3003
|
+
appendSocialMessage(data.platform, "assistant", data.content, "me");
|
|
3004
|
+
});
|
|
3005
|
+
|
|
3006
|
+
socket.on("messaging:disconnected", () => loadMessagingPage());
|
|
3007
|
+
socket.on("messaging:logged_out", () => loadMessagingPage());
|
|
3008
|
+
|
|
3009
|
+
socket.on("messaging:error", (data) => {
|
|
3010
|
+
toast(data && data.error ? data.error : "Messaging error", "error");
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
socket.on("messaging:blocked_sender", (data) => {
|
|
3014
|
+
// Show a persistent banner so the user can see the raw ID and add it to the whitelist
|
|
3015
|
+
const platform = data.platform || "whatsapp";
|
|
3016
|
+
const rawId = data.sender || data.chatId || "unknown";
|
|
3017
|
+
const bannerId = `blocked-banner-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
3018
|
+
if (document.getElementById(bannerId)) return; // don't stack duplicates
|
|
3019
|
+
|
|
3020
|
+
const platformLabel =
|
|
3021
|
+
platform === "telnyx"
|
|
3022
|
+
? "📞 Blocked call"
|
|
3023
|
+
: platform === "discord"
|
|
3024
|
+
? "🎮 Blocked Discord message"
|
|
3025
|
+
: platform === "telegram"
|
|
3026
|
+
? "✈️ Blocked Telegram message"
|
|
3027
|
+
: "⚠ Blocked message";
|
|
3028
|
+
|
|
3029
|
+
const banner = document.createElement("div");
|
|
3030
|
+
banner.id = bannerId;
|
|
3031
|
+
banner.style.cssText =
|
|
3032
|
+
"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;";
|
|
3033
|
+
banner.innerHTML = `
|
|
3034
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
|
|
3035
|
+
<div>
|
|
3036
|
+
<div style="font-weight:600;margin-bottom:4px;">${platformLabel}</div>
|
|
3037
|
+
<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 ? ` — ${escapeHtml(data.senderName)}` : ""}${data.meta ? ` <span style="font-size:0.78rem;">(${escapeHtml(data.meta)})</span>` : ""}</div>
|
|
3038
|
+
<div style="display:flex;flex-wrap:wrap;gap:8px;" id="wb-btns-${bannerId}">
|
|
3039
|
+
${data.suggestions && data.suggestions.length
|
|
3040
|
+
? data.suggestions
|
|
3041
|
+
.map(
|
|
3042
|
+
(s, i) =>
|
|
3043
|
+
`<button class="btn btn-sm btn-primary" id="wb-sug-${bannerId}-${i}" data-pid="${escapeHtml(s.prefixedId)}">${escapeHtml(s.label)}</button>`,
|
|
3044
|
+
)
|
|
3045
|
+
.join("")
|
|
3046
|
+
: `<button class="btn btn-sm btn-primary" id="wb-add-${bannerId}">Add to whitelist</button>`
|
|
3047
|
+
}
|
|
3048
|
+
<button class="btn btn-sm btn-secondary" id="wb-dismiss-${bannerId}">Dismiss</button>
|
|
3049
|
+
</div>
|
|
3050
|
+
</div>
|
|
3051
|
+
</div>`;
|
|
3052
|
+
|
|
3053
|
+
document.body.appendChild(banner);
|
|
3054
|
+
|
|
3055
|
+
document
|
|
3056
|
+
.getElementById(`wb-dismiss-${bannerId}`)
|
|
3057
|
+
.addEventListener("click", () => banner.remove());
|
|
3058
|
+
|
|
3059
|
+
// Helper: add a prefixed/plain ID to a platform whitelist, refresh cards
|
|
3060
|
+
async function _wbSave(platform, entryKey) {
|
|
3061
|
+
if (platform === "telnyx") {
|
|
3062
|
+
const s = await api("/settings");
|
|
3063
|
+
let list = [];
|
|
3064
|
+
try {
|
|
3065
|
+
list = JSON.parse(s.platform_whitelist_telnyx || "[]");
|
|
3066
|
+
if (!Array.isArray(list)) list = [];
|
|
3067
|
+
} catch {
|
|
3068
|
+
list = [];
|
|
3069
|
+
}
|
|
3070
|
+
if (!list.includes(entryKey)) list.push(entryKey);
|
|
3071
|
+
await api("/messaging/telnyx/whitelist", {
|
|
3072
|
+
method: "PUT",
|
|
3073
|
+
body: { numbers: list },
|
|
3074
|
+
});
|
|
3075
|
+
} else if (platform === "discord") {
|
|
3076
|
+
const s = await api("/settings");
|
|
3077
|
+
let list = [];
|
|
3078
|
+
try {
|
|
3079
|
+
list = JSON.parse(s.platform_whitelist_discord || "[]");
|
|
3080
|
+
if (!Array.isArray(list)) list = [];
|
|
3081
|
+
} catch {
|
|
3082
|
+
list = [];
|
|
3083
|
+
}
|
|
3084
|
+
const prefixed = entryKey.includes(":") ? entryKey : `user:${entryKey}`;
|
|
3085
|
+
if (!list.includes(prefixed)) list.push(prefixed);
|
|
3086
|
+
await api("/messaging/discord/whitelist", {
|
|
3087
|
+
method: "PUT",
|
|
3088
|
+
body: { ids: list },
|
|
3089
|
+
});
|
|
3090
|
+
} else if (platform === "telegram") {
|
|
3091
|
+
const s = await api("/settings");
|
|
3092
|
+
let list = [];
|
|
3093
|
+
try {
|
|
3094
|
+
list = JSON.parse(s.platform_whitelist_telegram || "[]");
|
|
3095
|
+
if (!Array.isArray(list)) list = [];
|
|
3096
|
+
} catch {
|
|
3097
|
+
list = [];
|
|
3098
|
+
}
|
|
3099
|
+
const prefixed = entryKey.includes(":") ? entryKey : `user:${entryKey}`;
|
|
3100
|
+
if (!list.includes(prefixed)) list.push(prefixed);
|
|
3101
|
+
await api("/messaging/telegram/whitelist", {
|
|
3102
|
+
method: "PUT",
|
|
3103
|
+
body: { ids: list },
|
|
3104
|
+
});
|
|
3105
|
+
} else {
|
|
3106
|
+
// whatsapp
|
|
3107
|
+
const s = await api("/settings");
|
|
3108
|
+
let list = [];
|
|
3109
|
+
try {
|
|
3110
|
+
list = JSON.parse(s.platform_whitelist_whatsapp || "[]");
|
|
3111
|
+
if (!Array.isArray(list)) list = [];
|
|
3112
|
+
} catch {
|
|
3113
|
+
list = [];
|
|
3114
|
+
}
|
|
3115
|
+
if (!list.includes(entryKey)) list.push(entryKey);
|
|
3116
|
+
await api("/settings", {
|
|
3117
|
+
method: "PUT",
|
|
3118
|
+
body: { platform_whitelist_whatsapp: JSON.stringify(list) },
|
|
3119
|
+
});
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
// Wire suggestion buttons (Discord) or the single Add button (other platforms)
|
|
3124
|
+
if (data.suggestions && data.suggestions.length) {
|
|
3125
|
+
data.suggestions.forEach((s, i) => {
|
|
3126
|
+
const btn = document.getElementById(`wb-sug-${bannerId}-${i}`);
|
|
3127
|
+
if (!btn) return;
|
|
3128
|
+
btn.addEventListener("click", async () => {
|
|
3129
|
+
try {
|
|
3130
|
+
await _wbSave(platform, s.prefixedId);
|
|
3131
|
+
toast(`Added ${s.prefixedId} to whitelist`, "success");
|
|
3132
|
+
banner.remove();
|
|
3133
|
+
if (document.querySelector("#page-messaging.active"))
|
|
3134
|
+
loadMessagingPage();
|
|
3135
|
+
} catch (err) {
|
|
3136
|
+
toast("Failed to save: " + err.message, "error");
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
});
|
|
3140
|
+
} else {
|
|
3141
|
+
const addBtn = document.getElementById(`wb-add-${bannerId}`);
|
|
3142
|
+
if (addBtn)
|
|
3143
|
+
addBtn.addEventListener("click", async () => {
|
|
3144
|
+
const digits = rawId.replace(/[^0-9]/g, "");
|
|
3145
|
+
const key = digits || rawId;
|
|
3146
|
+
try {
|
|
3147
|
+
await _wbSave(platform, key);
|
|
3148
|
+
toast(`Added ${key} to whitelist`, "success");
|
|
3149
|
+
banner.remove();
|
|
3150
|
+
if (document.querySelector("#page-messaging.active"))
|
|
3151
|
+
loadMessagingPage();
|
|
3152
|
+
} catch (err) {
|
|
3153
|
+
toast("Failed to save: " + err.message, "error");
|
|
3154
|
+
}
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
3157
|
+
});
|
|
3158
|
+
|
|
3159
|
+
// ── Browser Page (removed - integrated into flow) ──
|
|
3160
|
+
|
|
3161
|
+
// ── Init ──
|
|
3162
|
+
|
|
3163
|
+
// model is fixed: grok-4-1-fast-reasoning; nothing to load here
|
|
3164
|
+
|
|
3165
|
+
// ── Protocols ──
|
|
3166
|
+
let currentProtocolId = null;
|
|
3167
|
+
|
|
3168
|
+
async function loadProtocolsPage() {
|
|
3169
|
+
try {
|
|
3170
|
+
const res = await fetch("/api/protocols");
|
|
3171
|
+
if (!res.ok) throw new Error("Failed to load protocols");
|
|
3172
|
+
const protocols = await res.json();
|
|
3173
|
+
renderProtocolsList(protocols);
|
|
3174
|
+
} catch (err) {
|
|
3175
|
+
console.error(err);
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
function renderProtocolsList(protocols) {
|
|
3180
|
+
const container = $("#protocolsList");
|
|
3181
|
+
if (protocols.length === 0) {
|
|
3182
|
+
container.innerHTML =
|
|
3183
|
+
'<div class="empty-state">No protocols found. Create one.</div>';
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
container.className = "protocols-list";
|
|
3187
|
+
container.innerHTML = protocols
|
|
3188
|
+
.map(
|
|
3189
|
+
(p) => `
|
|
3190
|
+
<div class="item-card">
|
|
3191
|
+
<div class="item-card-header">
|
|
3192
|
+
<div class="item-card-title">${p.name}</div>
|
|
3193
|
+
<div class="item-card-actions">
|
|
3194
|
+
<button class="btn btn-sm btn-secondary" onclick="editProtocol(${p.id})">Edit</button>
|
|
3195
|
+
<button class="btn btn-sm btn-danger" onclick="deleteProtocol(${p.id})">×</button>
|
|
3196
|
+
</div>
|
|
3197
|
+
</div>
|
|
3198
|
+
<div class="item-card-meta">${p.description || "No description"}</div>
|
|
3199
|
+
</div>
|
|
3200
|
+
`,
|
|
3201
|
+
)
|
|
3202
|
+
.join("");
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
$("#closeProtocolModal")?.addEventListener("click", () =>
|
|
3206
|
+
$("#protocolModal")?.classList.add("hidden"),
|
|
3207
|
+
);
|
|
3208
|
+
$("#cancelProtocolModal")?.addEventListener("click", () =>
|
|
3209
|
+
$("#protocolModal")?.classList.add("hidden"),
|
|
3210
|
+
);
|
|
3211
|
+
|
|
3212
|
+
$("#addProtocolBtn")?.addEventListener("click", () => {
|
|
3213
|
+
currentProtocolId = null;
|
|
3214
|
+
$("#protocolModalTitle").textContent = "Add Protocol";
|
|
3215
|
+
$("#protocolName").value = "";
|
|
3216
|
+
$("#protocolDesc").value = "";
|
|
3217
|
+
$("#protocolContent").value = "";
|
|
3218
|
+
$("#protocolModal")?.classList.remove("hidden");
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
$("#saveProtocolBtn").addEventListener("click", async () => {
|
|
3222
|
+
const name = $("#protocolName").value.trim();
|
|
3223
|
+
const description = $("#protocolDesc").value.trim();
|
|
3224
|
+
const content = $("#protocolContent").value.trim();
|
|
3225
|
+
|
|
3226
|
+
if (!name || !content) {
|
|
3227
|
+
alert("Name and Content are required");
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
const payload = { name, description, content };
|
|
3232
|
+
const method = currentProtocolId ? "PUT" : "POST";
|
|
3233
|
+
const url = currentProtocolId
|
|
3234
|
+
? `/api/protocols/${currentProtocolId}`
|
|
3235
|
+
: "/api/protocols";
|
|
3236
|
+
|
|
3237
|
+
try {
|
|
3238
|
+
const res = await fetch(url, {
|
|
3239
|
+
method,
|
|
3240
|
+
headers: { "Content-Type": "application/json" },
|
|
3241
|
+
body: JSON.stringify(payload),
|
|
3242
|
+
});
|
|
3243
|
+
if (!res.ok) {
|
|
3244
|
+
const err = await res.json();
|
|
3245
|
+
throw new Error(err.error || "Failed to save: " + res.status);
|
|
3246
|
+
}
|
|
3247
|
+
$("#protocolModal")?.classList.add("hidden");
|
|
3248
|
+
loadProtocolsPage();
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
alert(err.message);
|
|
3251
|
+
}
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
async function editProtocol(id) {
|
|
3255
|
+
try {
|
|
3256
|
+
const res = await fetch(`/api/protocols/${id}`);
|
|
3257
|
+
if (!res.ok) throw new Error("Failed to load protocol");
|
|
3258
|
+
const p = await res.json();
|
|
3259
|
+
|
|
3260
|
+
currentProtocolId = p.id;
|
|
3261
|
+
$("#protocolModalTitle").textContent = "Edit Protocol";
|
|
3262
|
+
$("#protocolName").value = p.name;
|
|
3263
|
+
$("#protocolDesc").value = p.description || "";
|
|
3264
|
+
$("#protocolContent").value = p.content;
|
|
3265
|
+
$("#protocolModal")?.classList.remove("hidden");
|
|
3266
|
+
} catch (err) {
|
|
3267
|
+
alert(err.message);
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
async function deleteProtocol(id) {
|
|
3272
|
+
if (!confirm("Are you sure you want to delete this protocol?")) return;
|
|
3273
|
+
try {
|
|
3274
|
+
const res = await fetch(`/api/protocols/${id}`, { method: "DELETE" });
|
|
3275
|
+
if (!res.ok) throw new Error("Failed to delete protocol");
|
|
3276
|
+
loadProtocolsPage();
|
|
3277
|
+
} catch (err) {
|
|
3278
|
+
alert(err.message);
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
window.editProtocol = editProtocol;
|
|
3283
|
+
window.deleteProtocol = deleteProtocol;
|