@nordbyte/nordrelay 0.4.0 → 0.5.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 +155 -64
- package/README.md +80 -58
- package/dist/access-control.js +126 -114
- package/dist/agent-feature-matrix.js +42 -0
- package/dist/agent-updates.js +312 -0
- package/dist/bot-rendering.js +838 -0
- package/dist/bot.js +130 -1371
- package/dist/channel-actions.js +372 -0
- package/dist/channel-runtime.js +89 -0
- package/dist/config-metadata.js +238 -0
- package/dist/config.js +0 -58
- package/dist/index.js +8 -0
- package/dist/operations.js +33 -8
- package/dist/relay-runtime.js +159 -31
- package/dist/session-format.js +72 -3
- package/dist/settings-service.js +2 -117
- package/dist/telegram-access-commands.js +123 -0
- package/dist/telegram-access-middleware.js +129 -0
- package/dist/telegram-channel-runtime.js +132 -0
- package/dist/telegram-command-menu.js +54 -0
- package/dist/telegram-output.js +216 -0
- package/dist/telegram-update-commands.js +88 -0
- package/dist/user-management.js +708 -0
- package/dist/web-api-contract.js +56 -0
- package/dist/web-dashboard-assets.js +33 -0
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +649 -369
- package/dist/webui-assets/dashboard.css +919 -0
- package/dist/webui-assets/dashboard.js +1611 -0
- package/package.json +6 -3
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/commands/remote.md +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +283 -87
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0 };
|
|
3
|
+
async function api(path, options = {}) {
|
|
4
|
+
const headers = { ...options.body ? { "content-type": "application/json" } : {}, ...options.headers || {} };
|
|
5
|
+
const res = await fetch(path, { ...options, headers });
|
|
6
|
+
if (res.status === 401) {
|
|
7
|
+
location.reload();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const text = await res.text();
|
|
11
|
+
const data = text ? JSON.parse(text) : {};
|
|
12
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
function toast(msg, options = {}) {
|
|
16
|
+
const el = document.getElementById("toast");
|
|
17
|
+
el.textContent = msg;
|
|
18
|
+
el.style.display = "block";
|
|
19
|
+
if (state.toastTimer) clearTimeout(state.toastTimer);
|
|
20
|
+
state.toastTimer = null;
|
|
21
|
+
if (!options.sticky) {
|
|
22
|
+
state.toastTimer = setTimeout(() => {
|
|
23
|
+
el.style.display = "none";
|
|
24
|
+
state.toastTimer = null;
|
|
25
|
+
}, options.duration || 3500);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function esc(s) {
|
|
29
|
+
return String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
30
|
+
}
|
|
31
|
+
function attr(s) {
|
|
32
|
+
return esc(s).replace(/"/g, """);
|
|
33
|
+
}
|
|
34
|
+
function cssEscape(s) {
|
|
35
|
+
return window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/[^a-zA-Z0-9_-]/g, "\\\\$&");
|
|
36
|
+
}
|
|
37
|
+
function short(s, max = 250) {
|
|
38
|
+
const text = String(s ?? "");
|
|
39
|
+
return text.length > max ? text.slice(0, max - 1) + "..." : text;
|
|
40
|
+
}
|
|
41
|
+
async function copyText(text, label = "Copied") {
|
|
42
|
+
if (!text) return;
|
|
43
|
+
try {
|
|
44
|
+
await navigator.clipboard.writeText(text);
|
|
45
|
+
} catch {
|
|
46
|
+
const area = document.createElement("textarea");
|
|
47
|
+
area.value = text;
|
|
48
|
+
area.style.position = "fixed";
|
|
49
|
+
area.style.opacity = "0";
|
|
50
|
+
document.body.appendChild(area);
|
|
51
|
+
area.select();
|
|
52
|
+
document.execCommand("copy");
|
|
53
|
+
area.remove();
|
|
54
|
+
}
|
|
55
|
+
toast(label);
|
|
56
|
+
}
|
|
57
|
+
function fmtDate(s) {
|
|
58
|
+
return s ? new Date(s).toLocaleString() : "-";
|
|
59
|
+
}
|
|
60
|
+
function fmtDuration(ms) {
|
|
61
|
+
if (!ms && ms !== 0) return "-";
|
|
62
|
+
const sec = Math.round(ms / 1e3);
|
|
63
|
+
if (sec < 60) return sec + "s";
|
|
64
|
+
return Math.floor(sec / 60) + "m " + sec % 60 + "s";
|
|
65
|
+
}
|
|
66
|
+
function fmtBytes(n) {
|
|
67
|
+
if (n < 1024) return n + " B";
|
|
68
|
+
if (n < 1048576) return (n / 1024).toFixed(1).replace(/\\.0$/, "") + " KB";
|
|
69
|
+
return (n / 1048576).toFixed(1).replace(/\\.0$/, "") + " MB";
|
|
70
|
+
}
|
|
71
|
+
function compactNum(n) {
|
|
72
|
+
if (!n) return "";
|
|
73
|
+
if (n >= 1e9) return Math.round(n / 1e8) / 10 + "B";
|
|
74
|
+
if (n >= 1e6) return Math.round(n / 1e5) / 10 + "M";
|
|
75
|
+
if (n >= 1e3) return Math.round(n / 100) / 10 + "K";
|
|
76
|
+
return String(n);
|
|
77
|
+
}
|
|
78
|
+
function loadingHtml(label) {
|
|
79
|
+
return '<div class="loading-state"><span class="spinner"></span><span>' + esc(label || "Loading...") + "</span></div>";
|
|
80
|
+
}
|
|
81
|
+
function setLoading(id, label) {
|
|
82
|
+
const el = document.getElementById(id);
|
|
83
|
+
if (el) el.innerHTML = loadingHtml(label);
|
|
84
|
+
}
|
|
85
|
+
function can(permission) {
|
|
86
|
+
return !permission || (state.permissions || []).includes(permission);
|
|
87
|
+
}
|
|
88
|
+
function disabledAttr(permission) {
|
|
89
|
+
return can(permission) ? "" : ' disabled title="Permission required: ' + attr(permission) + '"';
|
|
90
|
+
}
|
|
91
|
+
function applyPermissions() {
|
|
92
|
+
document.querySelectorAll("[data-permission]").forEach((el) => {
|
|
93
|
+
const allowed = can(el.dataset.permission);
|
|
94
|
+
el.hidden = !allowed;
|
|
95
|
+
el.disabled = !allowed;
|
|
96
|
+
});
|
|
97
|
+
const currentButton = document.querySelector('nav button[data-page="' + cssEscape(state.currentPage) + '"]');
|
|
98
|
+
if (currentButton && currentButton.hidden) {
|
|
99
|
+
const first = [...document.querySelectorAll("nav button[data-page]")].find((b) => !b.hidden);
|
|
100
|
+
if (first) page(first.dataset.page);
|
|
101
|
+
}
|
|
102
|
+
const disableMap = [
|
|
103
|
+
["#promptForm > button,#promptInput", "prompt.send"],
|
|
104
|
+
["#fileInput,#recordBtn,#clearFilesBtn", "files.write"],
|
|
105
|
+
["#newSessionBtn,#attachBtn,#createSessionBtn", "sessions.write"],
|
|
106
|
+
["#retryBtn", "prompt.send"],
|
|
107
|
+
["#syncBtn,#handbackBtn", "sessions.write"],
|
|
108
|
+
["#abortBtn", "prompt.abort"],
|
|
109
|
+
["#clearChatBtn", "sessions.write"],
|
|
110
|
+
["#saveSettingsBtn", "settings.write"],
|
|
111
|
+
["#restartBtn", "system.restart"],
|
|
112
|
+
["#updateBtn", "updates.run"],
|
|
113
|
+
["#clearLogsBtn", "logs.clear"],
|
|
114
|
+
["#createUserBtn,#createGroupBtn,#createChatBtn", "users.write"],
|
|
115
|
+
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
116
|
+
["[data-switch]", "sessions.write"],
|
|
117
|
+
["[data-queue],[data-q]", "queue.write"],
|
|
118
|
+
["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
|
|
119
|
+
["[data-auth-login],[data-auth-logout]", "auth.manage"],
|
|
120
|
+
["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
|
|
121
|
+
["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle]", "users.write"]
|
|
122
|
+
];
|
|
123
|
+
disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
|
|
124
|
+
el.disabled = !can(permission);
|
|
125
|
+
if (!can(permission)) el.title = "Permission required: " + permission;
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
function modelLabel(m) {
|
|
129
|
+
const meta = [m.contextWindow ? compactNum(m.contextWindow) : "", m.supportsImages === true ? "img" : m.supportsImages === false ? "text" : "", m.supportsThinking === true ? "think" : ""].filter(Boolean).join(" ");
|
|
130
|
+
return (m.displayName || m.slug) + (meta ? " \xB7 " + meta : "");
|
|
131
|
+
}
|
|
132
|
+
function fmtAge(ms) {
|
|
133
|
+
const sec = Math.max(0, Math.floor(ms / 1e3));
|
|
134
|
+
if (sec < 60) return sec + "s ago";
|
|
135
|
+
const min = Math.floor(sec / 60);
|
|
136
|
+
if (min < 60) return min + "m ago";
|
|
137
|
+
return Math.floor(min / 60) + "h ago";
|
|
138
|
+
}
|
|
139
|
+
function isCliRunningStatus(msg) {
|
|
140
|
+
return / CLI running\\b/.test(String(msg || ""));
|
|
141
|
+
}
|
|
142
|
+
function isCliDoneStatus(msg) {
|
|
143
|
+
return / CLI task\\b/.test(String(msg || ""));
|
|
144
|
+
}
|
|
145
|
+
function applyTheme(theme) {
|
|
146
|
+
document.documentElement.dataset.theme = theme;
|
|
147
|
+
localStorage.setItem("nordrelayTheme", theme);
|
|
148
|
+
document.getElementById("themeBtn").textContent = theme === "dark" ? "Light" : "Dark";
|
|
149
|
+
}
|
|
150
|
+
function toggleTheme() {
|
|
151
|
+
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
|
|
152
|
+
}
|
|
153
|
+
function page(name) {
|
|
154
|
+
state.currentPage = name;
|
|
155
|
+
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
|
|
156
|
+
document.querySelectorAll(".page").forEach((p) => p.classList.toggle("active", p.id === "page-" + name));
|
|
157
|
+
document.getElementById("pageTitle").textContent = name[0].toUpperCase() + name.slice(1);
|
|
158
|
+
document.getElementById("sidebar").classList.remove("open");
|
|
159
|
+
void reloadCurrentPage().catch((err) => toast(err.message || String(err)));
|
|
160
|
+
}
|
|
161
|
+
async function reloadCurrentPage(options = {}) {
|
|
162
|
+
const name = state.currentPage;
|
|
163
|
+
if (name === "chat") {
|
|
164
|
+
await loadChatHistory();
|
|
165
|
+
scrollChatToBottom();
|
|
166
|
+
}
|
|
167
|
+
if (name === "sessions") await loadSessions(true, options.agentId);
|
|
168
|
+
if (name === "settings") await loadSettings();
|
|
169
|
+
if (name === "logs") await loadLogs();
|
|
170
|
+
if (name === "diagnostics") await loadDiagnostics();
|
|
171
|
+
if (name === "artifacts") await loadArtifacts();
|
|
172
|
+
if (name === "activity") await loadActivity();
|
|
173
|
+
if (name === "tasks") await loadTasks();
|
|
174
|
+
if (name === "adapters") await loadAdapterHealth();
|
|
175
|
+
if (name === "access") await loadAccess();
|
|
176
|
+
if (name === "version") await loadVersion();
|
|
177
|
+
}
|
|
178
|
+
document.querySelectorAll("nav button").forEach((b) => b.onclick = () => page(b.dataset.page));
|
|
179
|
+
document.getElementById("menuBtn").onclick = () => document.getElementById("sidebar").classList.toggle("open");
|
|
180
|
+
document.getElementById("refreshBtn").onclick = () => loadBootstrap();
|
|
181
|
+
document.getElementById("themeBtn").onclick = toggleTheme;
|
|
182
|
+
document.getElementById("logoutBtn").onclick = () => safe(async () => {
|
|
183
|
+
await api("/api/dashboard/logout", { method: "POST" });
|
|
184
|
+
location.href = "/";
|
|
185
|
+
});
|
|
186
|
+
applyTheme(localStorage.getItem("nordrelayTheme") || "light");
|
|
187
|
+
function createPaginator(containerId, onChange, pageSize = 50) {
|
|
188
|
+
const container = document.getElementById(containerId);
|
|
189
|
+
return {
|
|
190
|
+
page: 1,
|
|
191
|
+
pageSize,
|
|
192
|
+
reset() {
|
|
193
|
+
this.page = 1;
|
|
194
|
+
},
|
|
195
|
+
render(meta = {}) {
|
|
196
|
+
const hasPrevious = Boolean(meta.hasPrevious);
|
|
197
|
+
const hasNext = Boolean(meta.hasNext);
|
|
198
|
+
container.innerHTML = "<span>Page " + this.page + " / " + this.pageSize + ' per page</span><div class="pager-actions"><button data-page-action="prev" ' + (!hasPrevious ? "disabled" : "") + '>Previous</button><button data-page-action="next" ' + (!hasNext ? "disabled" : "") + ">Next</button></div>";
|
|
199
|
+
const prev = container.querySelector('[data-page-action="prev"]');
|
|
200
|
+
const next = container.querySelector('[data-page-action="next"]');
|
|
201
|
+
prev.onclick = () => {
|
|
202
|
+
if (hasPrevious) {
|
|
203
|
+
this.page -= 1;
|
|
204
|
+
onChange();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
next.onclick = () => {
|
|
208
|
+
if (hasNext) {
|
|
209
|
+
this.page += 1;
|
|
210
|
+
onChange();
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
|
|
217
|
+
async function loadBootstrap() {
|
|
218
|
+
const data = await api("/api/bootstrap");
|
|
219
|
+
state.auth = data.auth || null;
|
|
220
|
+
state.permissions = data.auth?.permissions || [];
|
|
221
|
+
state.snapshot = data.status.snapshot;
|
|
222
|
+
state.controls = data.controls;
|
|
223
|
+
state.enabledAgents = data.enabledAgents || [];
|
|
224
|
+
applyPermissions();
|
|
225
|
+
renderSnapshot(state.snapshot);
|
|
226
|
+
renderSessionControls();
|
|
227
|
+
populateNewSessionForm(data.enabledAgents);
|
|
228
|
+
renderAdapters(data.channels, data.agentAdapters);
|
|
229
|
+
document.getElementById("footerVersion").textContent = "NordRelay " + (data.status.health?.version || "");
|
|
230
|
+
document.getElementById("footerHealth").textContent = "Health: " + (data.status.health?.state?.status || "unknown");
|
|
231
|
+
document.getElementById("footerUser").textContent = "User: " + (data.auth?.user?.email || "-");
|
|
232
|
+
const agentSelect = document.getElementById("agentSelect");
|
|
233
|
+
agentSelect.innerHTML = data.enabledAgents.map((a) => '<option value="' + a + '">' + a + "</option>").join("");
|
|
234
|
+
agentSelect.value = state.snapshot.session.agentId;
|
|
235
|
+
agentSelect.onchange = () => safe(async () => {
|
|
236
|
+
const selected = agentSelect.value;
|
|
237
|
+
const r = await api("/api/agent", { method: "POST", body: JSON.stringify({ agentId: selected }) });
|
|
238
|
+
if (state.snapshot && r.session) {
|
|
239
|
+
state.snapshot.session = r.session;
|
|
240
|
+
renderSnapshot(state.snapshot);
|
|
241
|
+
}
|
|
242
|
+
toast("Agent switched");
|
|
243
|
+
await loadBootstrap();
|
|
244
|
+
await reloadCurrentPage({ agentId: selected });
|
|
245
|
+
});
|
|
246
|
+
applyPermissions();
|
|
247
|
+
}
|
|
248
|
+
function renderSnapshot(s) {
|
|
249
|
+
document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
|
|
250
|
+
document.getElementById("sessionText").textContent = s.sessionText || "";
|
|
251
|
+
document.getElementById("metrics").innerHTML = [
|
|
252
|
+
["Status", s.processing ? "working" : "idle"],
|
|
253
|
+
["Agent", s.session.agentLabel],
|
|
254
|
+
["Queue", s.queue.length],
|
|
255
|
+
["Workspace", s.session.workspace],
|
|
256
|
+
["Thread", s.session.threadId || "not started"],
|
|
257
|
+
["Reasoning", s.session.reasoningEffort || "default"],
|
|
258
|
+
["Fast", s.session.capabilities && s.session.capabilities.fastMode ? s.session.fastMode ? "on" : "off" : "n/a"]
|
|
259
|
+
].map(([k, v]) => '<div class="metric"><div class="label">' + esc(k) + '</div><div class="value">' + esc(v) + "</div></div>").join("");
|
|
260
|
+
renderQueue(s.queue, s.queuePaused);
|
|
261
|
+
}
|
|
262
|
+
function renderSessionControls() {
|
|
263
|
+
const c = state.controls || {};
|
|
264
|
+
const s = state.snapshot?.session || {};
|
|
265
|
+
const caps = c.capabilities || {};
|
|
266
|
+
const modelOptions = ['<option value="">Default</option>'].concat((c.models || []).map((m) => '<option value="' + attr(m.slug) + '" ' + (m.slug === s.model ? "selected" : "") + ">" + esc(modelLabel(m)) + "</option>")).join("");
|
|
267
|
+
const reasoningOptions = (c.reasoningOptions || []).map((v) => '<option value="' + attr(v) + '" ' + (v === s.reasoningEffort ? "selected" : "") + ">" + esc(v) + "</option>").join("");
|
|
268
|
+
const launchOptions = (c.launchProfiles || []).map((p) => '<option value="' + attr(p.id) + '" ' + (p.id === (s.nextLaunchProfileId || s.launchProfileId) ? "selected" : "") + ">" + esc(p.label + " - " + p.behavior + (p.unsafe ? " - unsafe" : "")) + "</option>").join("");
|
|
269
|
+
document.getElementById("sessionControls").innerHTML = [
|
|
270
|
+
caps.modelSelection ? '<label>Model<select id="controlModel"' + disabledAttr("settings.write") + ">" + modelOptions + "</select></label>" : "",
|
|
271
|
+
caps.reasoningSelection ? "<label>" + esc(c.reasoningLabel || "Reasoning") + '<select id="controlReasoning"' + disabledAttr("settings.write") + ">" + reasoningOptions + "</select></label>" : "",
|
|
272
|
+
caps.launchProfiles ? '<label>Launch<select id="controlLaunch"' + disabledAttr("settings.write") + ">" + launchOptions + "</select></label>" : "",
|
|
273
|
+
caps.fastMode ? '<label class="checkbox"><input id="controlFast" type="checkbox" ' + (s.fastMode ? "checked" : "") + disabledAttr("settings.write") + "> Fast mode</label>" : ""
|
|
274
|
+
].join("");
|
|
275
|
+
const model = document.getElementById("controlModel");
|
|
276
|
+
if (model) model.onchange = () => safe(async () => {
|
|
277
|
+
if (model.value) {
|
|
278
|
+
await api("/api/session/model", { method: "POST", body: JSON.stringify({ model: model.value }) });
|
|
279
|
+
toast("Model updated");
|
|
280
|
+
loadBootstrap();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
const reasoning = document.getElementById("controlReasoning");
|
|
284
|
+
if (reasoning) reasoning.onchange = () => safe(async () => {
|
|
285
|
+
await api("/api/session/reasoning", { method: "POST", body: JSON.stringify({ reasoning: reasoning.value }) });
|
|
286
|
+
toast((c.reasoningLabel || "Reasoning") + " updated");
|
|
287
|
+
loadBootstrap();
|
|
288
|
+
});
|
|
289
|
+
const launch = document.getElementById("controlLaunch");
|
|
290
|
+
if (launch) launch.onchange = () => safe(async () => {
|
|
291
|
+
await api("/api/session/launch", { method: "POST", body: JSON.stringify({ profileId: launch.value }) });
|
|
292
|
+
toast("Launch profile updated");
|
|
293
|
+
loadBootstrap();
|
|
294
|
+
});
|
|
295
|
+
const fast = document.getElementById("controlFast");
|
|
296
|
+
if (fast) fast.onchange = () => safe(async () => {
|
|
297
|
+
await api("/api/session/fast", { method: "POST", body: JSON.stringify({ enabled: fast.checked }) });
|
|
298
|
+
toast("Fast mode updated");
|
|
299
|
+
loadBootstrap();
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function renderAdapters(channels, agents) {
|
|
303
|
+
const channelCards = (channels || []).map((c) => adapterCard(c.label, c.status, "", c.capabilities.join(", ")));
|
|
304
|
+
const agentCards = (agents || []).map((a) => {
|
|
305
|
+
const available = a.status === "available";
|
|
306
|
+
const status = available ? state.enabledAgents.includes(a.id) ? "enabled" : "disabled" : a.status || "planned";
|
|
307
|
+
return adapterCard(a.label, status, "", a.notes || "");
|
|
308
|
+
});
|
|
309
|
+
document.getElementById("agentAdapters").innerHTML = '<div class="list">' + (agentCards.join("") || '<div class="item">No agent adapters.</div>') + "</div>";
|
|
310
|
+
document.getElementById("chatAdapters").innerHTML = '<div class="list">' + (channelCards.join("") || '<div class="item">No chat adapters.</div>') + "</div>";
|
|
311
|
+
}
|
|
312
|
+
function adapterCard(label, status, detail, tooltip = "") {
|
|
313
|
+
return '<div class="item"><strong title="' + attr(tooltip) + '">' + esc(label) + ' <span class="adapter-status ' + esc(status) + '">' + esc(status) + "</span></strong>" + (detail ? "<small>" + esc(detail) + "</small>" : "") + "</div>";
|
|
314
|
+
}
|
|
315
|
+
const agentFeatureDefs = [
|
|
316
|
+
["modelSelection", "Model", "Model selection"],
|
|
317
|
+
["reasoningSelection", "Reasoning", "Reasoning/thinking level selection"],
|
|
318
|
+
["launchProfiles", "Launch", "Launch profile selection"],
|
|
319
|
+
["fastMode", "Fast", "Fast mode"],
|
|
320
|
+
["workspaces", "Workspaces", "Workspace listing and switching"],
|
|
321
|
+
["attachments", "Files/images", "File, photo, and voice attachments"],
|
|
322
|
+
["externalActivity", "External busy", "Detect native CLI activity"],
|
|
323
|
+
["cliMirror", "CLI mirror", "Mirror native CLI turns"],
|
|
324
|
+
["activityLog", "Activity", "Session activity timeline"],
|
|
325
|
+
["usageStats", "Usage", "Token and context usage"],
|
|
326
|
+
["subscriptionLimits", "Limits", "Subscription/quota limits"],
|
|
327
|
+
["auth", "Auth", "Authentication status"],
|
|
328
|
+
["login", "Login", "Interactive login"],
|
|
329
|
+
["logout", "Logout", "Interactive logout"],
|
|
330
|
+
["handback", "Handback", "Return session to native CLI"]
|
|
331
|
+
];
|
|
332
|
+
function featureMatrix(caps) {
|
|
333
|
+
const c = caps || {};
|
|
334
|
+
return '<div class="feature-matrix">' + agentFeatureDefs.map(([key, label, title]) => '<span class="feature-chip ' + (c[key] ? "supported" : "unsupported") + '" title="' + attr(title) + '"><span>' + esc(label) + "</span><b>" + (c[key] ? "\u2713" : "-") + "</b></span>").join("") + "</div>";
|
|
335
|
+
}
|
|
336
|
+
function versionStatusLabel(status) {
|
|
337
|
+
if (status === "current") return "Latest";
|
|
338
|
+
if (status === "outdated") return "Outdated";
|
|
339
|
+
if (status === "not-installed") return "Not installed";
|
|
340
|
+
return "Unknown";
|
|
341
|
+
}
|
|
342
|
+
function versionStatusClass(status) {
|
|
343
|
+
if (status === "current") return "available";
|
|
344
|
+
if (status === "outdated") return "planned";
|
|
345
|
+
return "disabled";
|
|
346
|
+
}
|
|
347
|
+
function jobStatusClass(status) {
|
|
348
|
+
if (status === "completed") return "available";
|
|
349
|
+
if (status === "running") return "planned";
|
|
350
|
+
return "disabled";
|
|
351
|
+
}
|
|
352
|
+
function scrollChatToBottom() {
|
|
353
|
+
const box = document.getElementById("messages");
|
|
354
|
+
if (!box) return;
|
|
355
|
+
requestAnimationFrame(() => {
|
|
356
|
+
box.scrollTop = box.scrollHeight;
|
|
357
|
+
requestAnimationFrame(() => {
|
|
358
|
+
box.scrollTop = box.scrollHeight;
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function appendMessage(cls, text) {
|
|
363
|
+
const box = document.getElementById("messages");
|
|
364
|
+
const div = document.createElement("div");
|
|
365
|
+
div.className = "message " + cls;
|
|
366
|
+
div.textContent = text;
|
|
367
|
+
box.appendChild(div);
|
|
368
|
+
scrollChatToBottom();
|
|
369
|
+
return div;
|
|
370
|
+
}
|
|
371
|
+
function appendQueuedMessage(id) {
|
|
372
|
+
const div = appendMessage("system", "Queued prompt " + id);
|
|
373
|
+
const btn = document.createElement("button");
|
|
374
|
+
btn.textContent = "Cancel queued message";
|
|
375
|
+
btn.className = "danger";
|
|
376
|
+
btn.onclick = () => safe(async () => {
|
|
377
|
+
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: "cancel", id }) });
|
|
378
|
+
renderQueue(r.queue, r.paused);
|
|
379
|
+
div.textContent = "Cancelled queued prompt " + id;
|
|
380
|
+
});
|
|
381
|
+
div.appendChild(document.createElement("br"));
|
|
382
|
+
div.appendChild(btn);
|
|
383
|
+
}
|
|
384
|
+
function renderChatMessages(messages) {
|
|
385
|
+
state.chatMessages = messages || [];
|
|
386
|
+
const box = document.getElementById("messages");
|
|
387
|
+
box.innerHTML = (messages || []).map((m) => '<div class="message ' + esc(m.role) + '"><small>' + esc((m.source || "web") + " / " + fmtDate(m.timestamp)) + "</small>\\n" + esc(m.text) + "</div>").join("");
|
|
388
|
+
scrollChatToBottom();
|
|
389
|
+
}
|
|
390
|
+
async function loadChatHistory() {
|
|
391
|
+
const data = await api("/api/chat/history");
|
|
392
|
+
renderChatMessages(data.messages || []);
|
|
393
|
+
}
|
|
394
|
+
let currentAgentMessage = null;
|
|
395
|
+
function connectEvents() {
|
|
396
|
+
if (state.events) state.events.close();
|
|
397
|
+
const events = new EventSource("/api/events");
|
|
398
|
+
state.events = events;
|
|
399
|
+
setConnection("Connecting", "warn");
|
|
400
|
+
events.onopen = () => {
|
|
401
|
+
if (state.reconnectTimer) {
|
|
402
|
+
clearTimeout(state.reconnectTimer);
|
|
403
|
+
state.reconnectTimer = null;
|
|
404
|
+
}
|
|
405
|
+
setConnection("Live", "ok");
|
|
406
|
+
};
|
|
407
|
+
events.addEventListener("snapshot", (e) => {
|
|
408
|
+
const d = JSON.parse(e.data).data;
|
|
409
|
+
state.snapshot = d;
|
|
410
|
+
renderSnapshot(d);
|
|
411
|
+
renderSessionControls();
|
|
412
|
+
});
|
|
413
|
+
events.addEventListener("chat_history", (e) => renderChatMessages(JSON.parse(e.data).messages || []));
|
|
414
|
+
events.addEventListener("activity_update", (e) => renderActivity(JSON.parse(e.data).events || []));
|
|
415
|
+
events.addEventListener("session_update", (e) => {
|
|
416
|
+
loadBootstrap();
|
|
417
|
+
loadChatHistory();
|
|
418
|
+
});
|
|
419
|
+
events.addEventListener("agent_update", (e) => {
|
|
420
|
+
const d = JSON.parse(e.data);
|
|
421
|
+
upsertAgentUpdateJob(d.job);
|
|
422
|
+
if (state.currentPage === "version") {
|
|
423
|
+
renderAgentUpdateJobs();
|
|
424
|
+
if (d.job && d.job.status !== "running") setTimeout(loadVersion, 800);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
events.addEventListener("queue_update", (e) => {
|
|
428
|
+
const d = JSON.parse(e.data);
|
|
429
|
+
renderQueue(d.queue, d.paused);
|
|
430
|
+
});
|
|
431
|
+
events.addEventListener("turn_start", (e) => {
|
|
432
|
+
const d = JSON.parse(e.data);
|
|
433
|
+
appendMessage("user", d.prompt);
|
|
434
|
+
currentAgentMessage = appendMessage("agent", "");
|
|
435
|
+
if (state.currentPage === "tasks") loadTasks();
|
|
436
|
+
});
|
|
437
|
+
events.addEventListener("text_delta", (e) => {
|
|
438
|
+
const d = JSON.parse(e.data);
|
|
439
|
+
if (!currentAgentMessage) currentAgentMessage = appendMessage("agent", "");
|
|
440
|
+
currentAgentMessage.textContent += d.delta;
|
|
441
|
+
scrollChatToBottom();
|
|
442
|
+
if (state.currentPage === "tasks") loadTasks();
|
|
443
|
+
});
|
|
444
|
+
events.addEventListener("tool_start", (e) => {
|
|
445
|
+
const d = JSON.parse(e.data);
|
|
446
|
+
tool("tool", "Started " + d.toolName);
|
|
447
|
+
if (state.currentPage === "tasks") loadTasks();
|
|
448
|
+
});
|
|
449
|
+
events.addEventListener("tool_update", (e) => {
|
|
450
|
+
const d = JSON.parse(e.data);
|
|
451
|
+
if (d.partialResult) tool("tool", d.partialResult.slice(-600));
|
|
452
|
+
});
|
|
453
|
+
events.addEventListener("tool_end", (e) => {
|
|
454
|
+
const d = JSON.parse(e.data);
|
|
455
|
+
tool(d.isError ? "danger" : "tool", "Finished " + d.toolCallId + (d.isError ? " with error" : ""));
|
|
456
|
+
});
|
|
457
|
+
events.addEventListener("todo_update", (e) => {
|
|
458
|
+
const d = JSON.parse(e.data);
|
|
459
|
+
tool("tool", "Plan:\\n" + d.items.map((i) => (i.completed ? "[x] " : "[ ] ") + i.text).join("\\n"));
|
|
460
|
+
});
|
|
461
|
+
events.addEventListener("turn_error", (e) => {
|
|
462
|
+
const d = JSON.parse(e.data);
|
|
463
|
+
appendMessage("system", "Error: " + d.error);
|
|
464
|
+
currentAgentMessage = null;
|
|
465
|
+
});
|
|
466
|
+
events.addEventListener("turn_complete", () => {
|
|
467
|
+
currentAgentMessage = null;
|
|
468
|
+
notify("NordRelay turn finished", "The active task completed.");
|
|
469
|
+
loadBootstrap();
|
|
470
|
+
if (state.currentPage === "tasks") loadTasks();
|
|
471
|
+
});
|
|
472
|
+
events.addEventListener("status", (e) => {
|
|
473
|
+
const d = JSON.parse(e.data);
|
|
474
|
+
const msg = d.message || "";
|
|
475
|
+
if (isCliRunningStatus(msg)) {
|
|
476
|
+
state.cliStatusActive = true;
|
|
477
|
+
toast(msg, { sticky: true });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (isCliDoneStatus(msg)) state.cliStatusActive = false;
|
|
481
|
+
toast(msg);
|
|
482
|
+
});
|
|
483
|
+
events.onerror = () => {
|
|
484
|
+
setConnection("Reconnecting", "error");
|
|
485
|
+
if (!state.reconnectTimer) state.reconnectTimer = setTimeout(() => {
|
|
486
|
+
state.reconnectTimer = null;
|
|
487
|
+
connectEvents();
|
|
488
|
+
}, 5e3);
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function setConnection(text, kind) {
|
|
492
|
+
const el = document.getElementById("connectionStatus");
|
|
493
|
+
el.textContent = text;
|
|
494
|
+
el.className = "badge connection-" + kind;
|
|
495
|
+
}
|
|
496
|
+
async function enableNotifications() {
|
|
497
|
+
if (!("Notification" in window)) {
|
|
498
|
+
toast("Browser notifications are not supported");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const permission = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
|
|
502
|
+
state.notifications = permission === "granted";
|
|
503
|
+
toast(state.notifications ? "Browser notifications enabled" : "Browser notifications denied");
|
|
504
|
+
}
|
|
505
|
+
function notify(title, body) {
|
|
506
|
+
if (state.notifications && "Notification" in window && Notification.permission === "granted") new Notification(title, { body });
|
|
507
|
+
}
|
|
508
|
+
function toolAgeText(el) {
|
|
509
|
+
const created = Number(el.dataset.createdAt || Date.now());
|
|
510
|
+
return "Updated " + fmtAge(Date.now() - created);
|
|
511
|
+
}
|
|
512
|
+
function refreshToolTooltip() {
|
|
513
|
+
const tip = document.getElementById("toolTooltip");
|
|
514
|
+
if (tip && state.toolTooltipTarget) tip.textContent = toolAgeText(state.toolTooltipTarget);
|
|
515
|
+
}
|
|
516
|
+
function positionToolTooltip(event) {
|
|
517
|
+
const tip = document.getElementById("toolTooltip");
|
|
518
|
+
if (!tip || tip.style.display === "none") return;
|
|
519
|
+
const gap = 12;
|
|
520
|
+
const rect = tip.getBoundingClientRect();
|
|
521
|
+
let x = event.clientX + gap;
|
|
522
|
+
let y = event.clientY + gap;
|
|
523
|
+
if (x + rect.width > window.innerWidth - 8) x = event.clientX - rect.width - gap;
|
|
524
|
+
if (y + rect.height > window.innerHeight - 8) y = event.clientY - rect.height - gap;
|
|
525
|
+
tip.style.left = Math.max(8, x) + "px";
|
|
526
|
+
tip.style.top = Math.max(8, y) + "px";
|
|
527
|
+
}
|
|
528
|
+
function showToolTooltip(target, event) {
|
|
529
|
+
state.toolTooltipTarget = target;
|
|
530
|
+
const tip = document.getElementById("toolTooltip");
|
|
531
|
+
if (!tip) return;
|
|
532
|
+
refreshToolTooltip();
|
|
533
|
+
tip.style.display = "block";
|
|
534
|
+
positionToolTooltip(event);
|
|
535
|
+
if (state.toolTooltipTimer) clearInterval(state.toolTooltipTimer);
|
|
536
|
+
state.toolTooltipTimer = setInterval(refreshToolTooltip, 1e3);
|
|
537
|
+
}
|
|
538
|
+
function hideToolTooltip() {
|
|
539
|
+
const tip = document.getElementById("toolTooltip");
|
|
540
|
+
if (tip) tip.style.display = "none";
|
|
541
|
+
state.toolTooltipTarget = null;
|
|
542
|
+
if (state.toolTooltipTimer) clearInterval(state.toolTooltipTimer);
|
|
543
|
+
state.toolTooltipTimer = null;
|
|
544
|
+
}
|
|
545
|
+
function updateToolAgeTitles() {
|
|
546
|
+
document.querySelectorAll(".tool[data-created-at]").forEach((el) => el.setAttribute("aria-label", toolAgeText(el)));
|
|
547
|
+
}
|
|
548
|
+
const toolStreamEl = document.getElementById("toolStream");
|
|
549
|
+
toolStreamEl.addEventListener("mouseover", (e) => {
|
|
550
|
+
const target = e.target.closest?.(".tool[data-created-at]");
|
|
551
|
+
if (target && target !== state.toolTooltipTarget) showToolTooltip(target, e);
|
|
552
|
+
});
|
|
553
|
+
toolStreamEl.addEventListener("mousemove", (e) => positionToolTooltip(e));
|
|
554
|
+
toolStreamEl.addEventListener("mouseout", (e) => {
|
|
555
|
+
const target = e.target.closest?.(".tool[data-created-at]");
|
|
556
|
+
if (target && !target.contains(e.relatedTarget)) hideToolTooltip();
|
|
557
|
+
});
|
|
558
|
+
toolStreamEl.addEventListener("focusin", (e) => {
|
|
559
|
+
const target = e.target.closest?.(".tool[data-created-at]");
|
|
560
|
+
if (target) showToolTooltip(target, { clientX: target.getBoundingClientRect().left, clientY: target.getBoundingClientRect().bottom });
|
|
561
|
+
});
|
|
562
|
+
toolStreamEl.addEventListener("focusout", hideToolTooltip);
|
|
563
|
+
function tool(cls, text) {
|
|
564
|
+
const div = document.createElement("div");
|
|
565
|
+
div.className = "tool " + (cls === "danger" ? "danger" : "");
|
|
566
|
+
div.dataset.createdAt = String(Date.now());
|
|
567
|
+
div.tabIndex = 0;
|
|
568
|
+
div.textContent = text;
|
|
569
|
+
document.getElementById("toolStream").prepend(div);
|
|
570
|
+
updateToolAgeTitles();
|
|
571
|
+
}
|
|
572
|
+
setInterval(updateToolAgeTitles, 3e4);
|
|
573
|
+
let selectedFiles = [];
|
|
574
|
+
function renderSelectedFiles() {
|
|
575
|
+
const summary = document.getElementById("fileSummary");
|
|
576
|
+
if (selectedFiles.length === 0) {
|
|
577
|
+
summary.textContent = "No files selected";
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const names = selectedFiles.slice(0, 3).map((f) => f.name || "file").join(", ");
|
|
581
|
+
const more = selectedFiles.length > 3 ? " +" + (selectedFiles.length - 3) + " more" : "";
|
|
582
|
+
const bytes = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
|
583
|
+
summary.textContent = names + more + " (" + fmtBytes(bytes) + ")";
|
|
584
|
+
}
|
|
585
|
+
function addFiles(files) {
|
|
586
|
+
selectedFiles = selectedFiles.concat(Array.from(files || []));
|
|
587
|
+
renderSelectedFiles();
|
|
588
|
+
}
|
|
589
|
+
async function filePayload(file) {
|
|
590
|
+
return { name: file.name || "upload", mimeType: file.type || "application/octet-stream", dataBase64: await fileToBase64(file) };
|
|
591
|
+
}
|
|
592
|
+
async function fileToBase64(file) {
|
|
593
|
+
const buffer = await file.arrayBuffer();
|
|
594
|
+
const bytes = new Uint8Array(buffer);
|
|
595
|
+
let binary = "";
|
|
596
|
+
const chunk = 32768;
|
|
597
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
598
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
599
|
+
}
|
|
600
|
+
return btoa(binary);
|
|
601
|
+
}
|
|
602
|
+
document.getElementById("fileInput").onchange = (e) => {
|
|
603
|
+
if (!can("files.write")) {
|
|
604
|
+
toast("Permission required: files.write");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
addFiles(e.target.files);
|
|
608
|
+
};
|
|
609
|
+
document.getElementById("clearFilesBtn").onclick = () => {
|
|
610
|
+
selectedFiles = [];
|
|
611
|
+
document.getElementById("fileInput").value = "";
|
|
612
|
+
renderSelectedFiles();
|
|
613
|
+
};
|
|
614
|
+
document.addEventListener("paste", (e) => {
|
|
615
|
+
const files = Array.from(e.clipboardData?.files || []);
|
|
616
|
+
if (files.length) {
|
|
617
|
+
if (!can("files.write")) {
|
|
618
|
+
toast("Permission required: files.write");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
addFiles(files);
|
|
622
|
+
toast("Pasted " + files.length + " file(s)");
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
document.addEventListener("dragover", (e) => {
|
|
626
|
+
e.preventDefault();
|
|
627
|
+
document.body.classList.add("drop-active");
|
|
628
|
+
});
|
|
629
|
+
document.addEventListener("dragleave", () => document.body.classList.remove("drop-active"));
|
|
630
|
+
document.addEventListener("drop", (e) => {
|
|
631
|
+
e.preventDefault();
|
|
632
|
+
document.body.classList.remove("drop-active");
|
|
633
|
+
const files = Array.from(e.dataTransfer?.files || []);
|
|
634
|
+
if (files.length) {
|
|
635
|
+
if (!can("files.write")) {
|
|
636
|
+
toast("Permission required: files.write");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
addFiles(files);
|
|
640
|
+
toast("Added " + files.length + " dropped file(s)");
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
document.getElementById("promptForm").onsubmit = (e) => safe(async () => {
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
if (!can("prompt.send")) {
|
|
646
|
+
toast("Permission required: prompt.send");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (selectedFiles.length && !can("files.write")) {
|
|
650
|
+
toast("Permission required: files.write");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const input = document.getElementById("promptInput");
|
|
654
|
+
const text = input.value.trim();
|
|
655
|
+
if (!text && selectedFiles.length === 0) return;
|
|
656
|
+
const files = selectedFiles;
|
|
657
|
+
input.value = "";
|
|
658
|
+
selectedFiles = [];
|
|
659
|
+
document.getElementById("fileInput").value = "";
|
|
660
|
+
renderSelectedFiles();
|
|
661
|
+
const payloadFiles = files.length ? await Promise.all(files.map(filePayload)) : [];
|
|
662
|
+
const r = files.length ? await api("/api/prompt/upload", { method: "POST", body: JSON.stringify({ text, files: payloadFiles }) }) : await api("/api/prompt", { method: "POST", body: JSON.stringify({ text }) });
|
|
663
|
+
if (r.transcribeOnly) appendMessage("system", "Transcribed audio:\\n" + (r.transcript || "(empty)"));
|
|
664
|
+
else if (r.queued) appendQueuedMessage(r.queueId);
|
|
665
|
+
}, e);
|
|
666
|
+
document.getElementById("newSessionBtn").onclick = () => {
|
|
667
|
+
if (!can("sessions.write")) {
|
|
668
|
+
toast("Permission required: sessions.write");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
openNewSessionDialog();
|
|
672
|
+
};
|
|
673
|
+
document.getElementById("retryBtn").onclick = () => safe(async () => {
|
|
674
|
+
if (!can("prompt.send")) {
|
|
675
|
+
toast("Permission required: prompt.send");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const r = await api("/api/retry", { method: "POST" });
|
|
679
|
+
toast(r.queued ? "Retry queued " + r.queueId : "Retry started");
|
|
680
|
+
});
|
|
681
|
+
document.getElementById("editLastBtn").onclick = () => {
|
|
682
|
+
const last = [...state.chatMessages || []].reverse().find((m) => m.role === "user");
|
|
683
|
+
if (last) {
|
|
684
|
+
document.getElementById("promptInput").value = last.text;
|
|
685
|
+
document.getElementById("promptInput").focus();
|
|
686
|
+
} else toast("No user prompt found");
|
|
687
|
+
};
|
|
688
|
+
document.getElementById("syncBtn").onclick = () => safe(async () => {
|
|
689
|
+
if (!can("sessions.write")) {
|
|
690
|
+
toast("Permission required: sessions.write");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const r = await api("/api/sync", { method: "POST" });
|
|
694
|
+
toast(r.changed ? "Synced: " + (r.changedFields || []).join(", ") : "Already in sync");
|
|
695
|
+
loadBootstrap();
|
|
696
|
+
});
|
|
697
|
+
document.getElementById("notifyBtn").onclick = () => enableNotifications();
|
|
698
|
+
document.getElementById("clearChatBtn").onclick = () => safe(async () => {
|
|
699
|
+
if (!can("sessions.write")) {
|
|
700
|
+
toast("Permission required: sessions.write");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (confirm("Clear chat history for the current thread?")) {
|
|
704
|
+
const r = await api("/api/chat/history", { method: "DELETE" });
|
|
705
|
+
renderChatMessages(r.messages || []);
|
|
706
|
+
toast("Removed " + r.removed + " messages");
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
document.getElementById("abortBtn").onclick = () => safe(async () => {
|
|
710
|
+
if (!can("prompt.abort")) {
|
|
711
|
+
toast("Permission required: prompt.abort");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
await api("/api/abort", { method: "POST" });
|
|
715
|
+
toast("Abort sent");
|
|
716
|
+
});
|
|
717
|
+
document.getElementById("handbackBtn").onclick = () => safe(async () => {
|
|
718
|
+
if (!can("sessions.write")) {
|
|
719
|
+
toast("Permission required: sessions.write");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const r = await api("/api/handback", { method: "POST" });
|
|
723
|
+
appendMessage("system", "Handback command:\\n" + (r.command || "No command available"));
|
|
724
|
+
});
|
|
725
|
+
document.getElementById("recordBtn").onclick = () => safe(async () => {
|
|
726
|
+
if (!can("files.write")) {
|
|
727
|
+
toast("Permission required: files.write");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const btn = document.getElementById("recordBtn");
|
|
731
|
+
if (state.mediaRecorder && state.mediaRecorder.state === "recording") {
|
|
732
|
+
state.mediaRecorder.stop();
|
|
733
|
+
btn.textContent = "Record voice";
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
737
|
+
state.recordedChunks = [];
|
|
738
|
+
state.mediaRecorder = new MediaRecorder(stream);
|
|
739
|
+
state.mediaRecorder.ondataavailable = (e) => {
|
|
740
|
+
if (e.data.size > 0) state.recordedChunks.push(e.data);
|
|
741
|
+
};
|
|
742
|
+
state.mediaRecorder.onstop = () => {
|
|
743
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
744
|
+
const blob = new Blob(state.recordedChunks, { type: "audio/webm" });
|
|
745
|
+
addFiles([new File([blob], "voice-note.webm", { type: "audio/webm" })]);
|
|
746
|
+
toast("Voice note attached");
|
|
747
|
+
};
|
|
748
|
+
state.mediaRecorder.start();
|
|
749
|
+
btn.textContent = "Stop recording";
|
|
750
|
+
});
|
|
751
|
+
function renderNewSessionControls(c) {
|
|
752
|
+
const s = state.snapshot?.session || {};
|
|
753
|
+
const caps = c.capabilities || {};
|
|
754
|
+
document.getElementById("workspaceOptions").innerHTML = (c.workspaces || []).map((w) => '<option value="' + attr(w) + '"></option>').join("");
|
|
755
|
+
document.getElementById("newModel").innerHTML = '<option value="">Default</option>' + (c.models || []).map((m) => '<option value="' + attr(m.slug) + '">' + esc(modelLabel(m)) + "</option>").join("");
|
|
756
|
+
document.getElementById("newModel").parentElement.style.display = caps.modelSelection ? "grid" : "none";
|
|
757
|
+
const reasoningWrap = document.getElementById("newReasoningWrap");
|
|
758
|
+
reasoningWrap.firstChild.nodeValue = c.reasoningLabel || "Reasoning";
|
|
759
|
+
reasoningWrap.style.display = caps.reasoningSelection ? "grid" : "none";
|
|
760
|
+
document.getElementById("newReasoning").innerHTML = '<option value="">Default</option>' + (c.reasoningOptions || []).map((v) => '<option value="' + attr(v) + '">' + esc(v) + "</option>").join("");
|
|
761
|
+
document.getElementById("newLaunch").innerHTML = '<option value="">Default</option>' + (c.launchProfiles || []).map((p) => '<option value="' + attr(p.id) + '">' + esc(p.label + " - " + p.behavior) + "</option>").join("");
|
|
762
|
+
document.getElementById("newFast").checked = Boolean(s.fastMode && caps.fastMode);
|
|
763
|
+
document.getElementById("newLaunchWrap").style.display = caps.launchProfiles ? "grid" : "none";
|
|
764
|
+
document.getElementById("newFastWrap").style.display = caps.fastMode ? "inline-flex" : "none";
|
|
765
|
+
}
|
|
766
|
+
function populateNewSessionForm(agents) {
|
|
767
|
+
const s = state.snapshot?.session || {};
|
|
768
|
+
const agentSelect = document.getElementById("newAgent");
|
|
769
|
+
agentSelect.innerHTML = (agents || []).map((a) => '<option value="' + attr(a) + '" ' + (a === s.agentId ? "selected" : "") + ">" + esc(a) + "</option>").join("");
|
|
770
|
+
agentSelect.value = s.agentId || agentSelect.value;
|
|
771
|
+
document.getElementById("newWorkspace").value = s.workspace || "";
|
|
772
|
+
state.newSessionControls = state.controls || {};
|
|
773
|
+
renderNewSessionControls(state.newSessionControls);
|
|
774
|
+
agentSelect.onchange = () => safe(async () => {
|
|
775
|
+
state.newSessionControls = await api("/api/control-options?agent=" + encodeURIComponent(agentSelect.value));
|
|
776
|
+
renderNewSessionControls(state.newSessionControls);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
function openNewSessionDialog() {
|
|
780
|
+
populateNewSessionForm(state.enabledAgents);
|
|
781
|
+
document.getElementById("newSessionDialog").showModal();
|
|
782
|
+
}
|
|
783
|
+
document.getElementById("newSessionForm").onsubmit = (e) => safe(async () => {
|
|
784
|
+
e.preventDefault();
|
|
785
|
+
if (!can("sessions.write")) {
|
|
786
|
+
toast("Permission required: sessions.write");
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const payload = { agentId: val("newAgent"), workspace: val("newWorkspace") || void 0, model: val("newModel") || void 0, reasoningEffort: val("newReasoning") || void 0, launchProfileId: val("newLaunch") || void 0, fastMode: document.getElementById("newFast").checked };
|
|
790
|
+
await api("/api/sessions/new", { method: "POST", body: JSON.stringify(payload) });
|
|
791
|
+
document.getElementById("newSessionDialog").close();
|
|
792
|
+
toast("New session started");
|
|
793
|
+
await loadBootstrap();
|
|
794
|
+
await loadChatHistory();
|
|
795
|
+
}, e);
|
|
796
|
+
document.getElementById("cancelSessionBtn").onclick = () => document.getElementById("newSessionDialog").close();
|
|
797
|
+
function val(id) {
|
|
798
|
+
return document.getElementById(id).value.trim();
|
|
799
|
+
}
|
|
800
|
+
function activeAgentId() {
|
|
801
|
+
return state.snapshot?.session?.agentId || document.getElementById("agentSelect").value || "";
|
|
802
|
+
}
|
|
803
|
+
async function loadSessions(reset = true, agentId) {
|
|
804
|
+
if (reset) sessionsPager.reset();
|
|
805
|
+
const expectedAgent = agentId || activeAgentId();
|
|
806
|
+
const requestId = ++state.sessionsRequestId;
|
|
807
|
+
setLoading("sessionsList", "Loading " + (expectedAgent || "agent") + " sessions...");
|
|
808
|
+
const q = document.getElementById("sessionSearch").value || "";
|
|
809
|
+
const agentParam = expectedAgent ? "&agent=" + encodeURIComponent(expectedAgent) : "";
|
|
810
|
+
const data = await api("/api/sessions?query=" + encodeURIComponent(q) + "&page=" + sessionsPager.page + "&limit=" + sessionsPager.pageSize + agentParam);
|
|
811
|
+
if (requestId !== state.sessionsRequestId || expectedAgent !== activeAgentId()) return;
|
|
812
|
+
document.getElementById("sessionsList").innerHTML = data.sessions.map((s) => '<div class="item"><strong title="' + attr(s.title || s.firstUserMessage || s.id) + '">' + esc(short(s.title || s.firstUserMessage || s.id)) + '</strong><small><button type="button" class="copy-id" data-copy-id="' + attr(s.id) + '" title="Copy thread ID">' + esc(short(s.id, 64)) + "</button> / " + esc(short((s.cwd || "") + " / " + fmtDate(s.updatedAt))) + '</small><div class="row"><button data-switch="' + attr(s.id) + '"' + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-session-detail="' + attr(s.id) + '">Details</button></div></div>').join("") || '<div class="item">No sessions found.</div>';
|
|
813
|
+
sessionsPager.render(data.pagination || {});
|
|
814
|
+
document.querySelectorAll("[data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
|
|
815
|
+
document.querySelectorAll("[data-switch]").forEach((b) => b.onclick = () => safe(async () => {
|
|
816
|
+
if (!can("sessions.write")) {
|
|
817
|
+
toast("Permission required: sessions.write");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
await api("/api/sessions/switch", { method: "POST", body: JSON.stringify({ threadId: b.dataset.switch }) });
|
|
821
|
+
toast("Session switched");
|
|
822
|
+
loadBootstrap();
|
|
823
|
+
}));
|
|
824
|
+
document.querySelectorAll("[data-session-detail]").forEach((b) => b.onclick = () => safe(() => loadSessionDetail(b.dataset.sessionDetail)));
|
|
825
|
+
applyPermissions();
|
|
826
|
+
}
|
|
827
|
+
function usageRows(rows) {
|
|
828
|
+
return (rows || []).map((row) => {
|
|
829
|
+
if (Array.isArray(row)) return [row[0], row[1]];
|
|
830
|
+
const text = String(row);
|
|
831
|
+
const index = text.indexOf(":");
|
|
832
|
+
return index > 0 ? [text.slice(0, index), text.slice(index + 1).trim()] : [text, ""];
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
function detailSection(title, count, body) {
|
|
836
|
+
return '<details class="session-detail-section"><summary>' + esc(title + " (" + count + ")") + '</summary><div class="list">' + (body || '<div class="item">No entries.</div>') + "</div></details>";
|
|
837
|
+
}
|
|
838
|
+
async function loadSessionDetail(threadId) {
|
|
839
|
+
const d = await api("/api/sessions/detail?threadId=" + encodeURIComponent(threadId));
|
|
840
|
+
const r = d.record || {};
|
|
841
|
+
const messages = d.messages || [];
|
|
842
|
+
const activity = d.activity || [];
|
|
843
|
+
const metadataRows = [["Thread", threadId], ["Agent", r.agentId], ["Title", r.title], ["Workspace", r.cwd], ["Model", r.model], ["Reasoning", r.reasoningEffort], ["Updated", fmtDate(r.updatedAt)], ["Path", r.sessionPath]].concat(usageRows(d.usageRows));
|
|
844
|
+
const messageItems = messages.slice(-20).map((m) => '<div class="item"><strong>' + esc(m.role + " / " + fmtDate(m.timestamp)) + "</strong><small>" + esc(short(m.text, 500)) + "</small></div>").join("");
|
|
845
|
+
const activityItems = activity.map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.type + " / " + fmtDate(e.timestamp)) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("");
|
|
846
|
+
document.getElementById("sessionDetail").innerHTML = "<h2>Session detail</h2>" + card("Metadata", metadataRows) + detailSection("Recent messages", messages.length, messageItems) + detailSection("Activity", activity.length, activityItems);
|
|
847
|
+
document.getElementById("sessionDetailDialog").showModal();
|
|
848
|
+
}
|
|
849
|
+
document.getElementById("closeSessionDetailBtn").onclick = () => document.getElementById("sessionDetailDialog").close();
|
|
850
|
+
document.getElementById("sessionSearchBtn").onclick = () => loadSessions(true);
|
|
851
|
+
document.getElementById("sessionSearch").addEventListener("keydown", (e) => {
|
|
852
|
+
if (e.key === "Enter") loadSessions(true);
|
|
853
|
+
});
|
|
854
|
+
document.getElementById("attachBtn").onclick = async () => {
|
|
855
|
+
if (!can("sessions.write")) {
|
|
856
|
+
toast("Permission required: sessions.write");
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const threadId = document.getElementById("attachInput").value.trim();
|
|
860
|
+
if (threadId) {
|
|
861
|
+
await api("/api/sessions/attach", { method: "POST", body: JSON.stringify({ threadId }) });
|
|
862
|
+
toast("Session attached");
|
|
863
|
+
loadBootstrap();
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
function renderQueue(queue, paused) {
|
|
867
|
+
document.getElementById("queueStatus").textContent = paused ? "Paused" : "Running";
|
|
868
|
+
document.getElementById("queueList").innerHTML = (queue || []).map((q, i) => '<div class="item queue-item" draggable="true" data-queue-id="' + attr(q.id) + '"><strong>' + esc(i + 1 + ". " + q.id + " - " + q.description) + "</strong><small>Created " + fmtDate(q.createdAt) + " / attempts " + q.attempts + (q.lastError ? " / " + esc(q.lastError) : "") + '</small><div class="row"><button data-q="run" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="top" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Top</button><button data-q="up" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Up</button><button data-q="down" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Down</button><button data-q="cancel" data-id="' + q.id + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>';
|
|
869
|
+
document.querySelectorAll("[data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
870
|
+
if (!can("queue.write")) {
|
|
871
|
+
toast("Permission required: queue.write");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
|
|
875
|
+
renderQueue(r.queue, r.paused);
|
|
876
|
+
}));
|
|
877
|
+
let dragged = null;
|
|
878
|
+
document.querySelectorAll(".queue-item").forEach((item) => {
|
|
879
|
+
item.ondragstart = () => {
|
|
880
|
+
if (!can("queue.write")) return;
|
|
881
|
+
dragged = item.dataset.queueId;
|
|
882
|
+
item.classList.add("dragging");
|
|
883
|
+
};
|
|
884
|
+
item.ondragend = () => item.classList.remove("dragging");
|
|
885
|
+
item.ondragover = (e) => {
|
|
886
|
+
if (can("queue.write")) e.preventDefault();
|
|
887
|
+
};
|
|
888
|
+
item.ondrop = () => safe(async () => {
|
|
889
|
+
if (!can("queue.write")) {
|
|
890
|
+
toast("Permission required: queue.write");
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (dragged && dragged !== item.dataset.queueId) {
|
|
894
|
+
const ids = Array.from(document.querySelectorAll(".queue-item")).map((el) => el.dataset.queueId);
|
|
895
|
+
const targetIndex = Math.max(0, ids.indexOf(item.dataset.queueId));
|
|
896
|
+
await api("/api/queue", { method: "POST", body: JSON.stringify({ action: "top", id: dragged }) });
|
|
897
|
+
for (let i = 0; i < targetIndex; i++) await api("/api/queue", { method: "POST", body: JSON.stringify({ action: "down", id: dragged }) });
|
|
898
|
+
const r = await api("/api/queue");
|
|
899
|
+
renderQueue(r.queue, r.paused);
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
applyPermissions();
|
|
904
|
+
}
|
|
905
|
+
document.querySelectorAll("[data-queue]").forEach((b) => b.onclick = () => safe(async () => {
|
|
906
|
+
if (!can("queue.write")) {
|
|
907
|
+
toast("Permission required: queue.write");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
|
|
911
|
+
renderQueue(r.queue, r.paused);
|
|
912
|
+
}));
|
|
913
|
+
async function loadTasks() {
|
|
914
|
+
setLoading("tasksList", "Loading tasks...");
|
|
915
|
+
const d = await api("/api/tasks");
|
|
916
|
+
renderTasks(d);
|
|
917
|
+
}
|
|
918
|
+
function taskCard(t, title) {
|
|
919
|
+
if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
|
|
920
|
+
const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
|
|
921
|
+
return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
|
|
922
|
+
}
|
|
923
|
+
function renderTasks(d) {
|
|
924
|
+
document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
|
|
925
|
+
document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
926
|
+
if (!can("queue.write")) {
|
|
927
|
+
toast("Permission required: queue.write");
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
|
|
931
|
+
renderQueue(r.queue, r.paused);
|
|
932
|
+
loadTasks();
|
|
933
|
+
}));
|
|
934
|
+
applyPermissions();
|
|
935
|
+
}
|
|
936
|
+
document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
|
|
937
|
+
async function loadArtifacts() {
|
|
938
|
+
setLoading("artifactList", "Loading artifacts...");
|
|
939
|
+
document.getElementById("artifactPreview").innerHTML = "";
|
|
940
|
+
const data = await api("/api/artifacts");
|
|
941
|
+
state.artifactReports = data.reports || [];
|
|
942
|
+
renderArtifacts();
|
|
943
|
+
}
|
|
944
|
+
function artifactMatches(a, kind, query) {
|
|
945
|
+
const name = (a.name || a.relativePath || "").toLowerCase();
|
|
946
|
+
if (query && !name.includes(query)) return false;
|
|
947
|
+
if (kind === "images") return /\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
|
|
948
|
+
if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
function renderArtifacts() {
|
|
952
|
+
const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
|
|
953
|
+
const kind = document.getElementById("artifactKind").value;
|
|
954
|
+
const reports = state.artifactReports || [];
|
|
955
|
+
document.getElementById("artifactList").innerHTML = reports.map((r) => {
|
|
956
|
+
const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
|
|
957
|
+
if (files.length === 0) return "";
|
|
958
|
+
const gallery = files.map((a) => {
|
|
959
|
+
const href = "/api/artifacts/file?turnId=" + encodeURIComponent(r.turnId) + "&path=" + encodeURIComponent(a.relativePath);
|
|
960
|
+
const img = /\\.(png|jpe?g|gif|webp|svg)$/i.test(a.name) ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
|
|
961
|
+
return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="' + attr(r.turnId) + '" ' + (state.selectedArtifactTurns.has(r.turnId) ? "checked" : "") + "> " + esc(short(a.name, 32)) + "</label>" + img + "<small>" + esc(fmtBytes(a.sizeBytes)) + '</small><div class="row"><a href="' + href + '">Open</a><button class="secondary" data-preview-turn="' + attr(r.turnId) + '" data-preview-path="' + attr(a.relativePath) + '">Preview</button></div></div>';
|
|
962
|
+
}).join("");
|
|
963
|
+
return '<div class="item"><strong>' + esc(r.turnId) + " - " + files.length + "/" + r.fileCount + " files - " + fmtBytes(r.totalSizeBytes) + "</strong><small>" + fmtDate(r.updatedAt) + " / " + esc(r.source || "turn") + '</small><div class="row"><a href="/api/artifacts/zip?turnId=' + encodeURIComponent(r.turnId) + '">Download ZIP</a><button data-del-art="' + esc(r.turnId) + '" class="danger"' + disabledAttr("files.write") + '>Delete</button></div><div class="gallery">' + gallery + "</div></div>";
|
|
964
|
+
}).join("") || '<div class="item">No artifacts.</div>';
|
|
965
|
+
document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
|
|
966
|
+
if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
|
|
967
|
+
else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
|
|
968
|
+
});
|
|
969
|
+
document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
|
|
970
|
+
if (!can("files.write")) {
|
|
971
|
+
toast("Permission required: files.write");
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (confirm("Delete artifact turn " + b.dataset.delArt + "?")) {
|
|
975
|
+
await api("/api/artifacts?turnId=" + encodeURIComponent(b.dataset.delArt), { method: "DELETE" });
|
|
976
|
+
state.selectedArtifactTurns.delete(b.dataset.delArt);
|
|
977
|
+
loadArtifacts();
|
|
978
|
+
}
|
|
979
|
+
}));
|
|
980
|
+
document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
|
|
981
|
+
applyPermissions();
|
|
982
|
+
}
|
|
983
|
+
document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
|
|
984
|
+
document.getElementById("artifactSearch").oninput = renderArtifacts;
|
|
985
|
+
document.getElementById("artifactKind").onchange = renderArtifacts;
|
|
986
|
+
document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
|
|
987
|
+
const turnIds = [...state.selectedArtifactTurns];
|
|
988
|
+
if (turnIds.length === 0) {
|
|
989
|
+
toast("No artifact turns selected");
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
turnIds.forEach((turnId) => window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
|
|
993
|
+
};
|
|
994
|
+
document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
|
|
995
|
+
if (!can("files.write")) {
|
|
996
|
+
toast("Permission required: files.write");
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const turnIds = [...state.selectedArtifactTurns];
|
|
1000
|
+
if (turnIds.length === 0) {
|
|
1001
|
+
toast("No artifact turns selected");
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (confirm("Delete " + turnIds.length + " selected artifact turn(s)?")) {
|
|
1005
|
+
const r = await api("/api/artifacts/bulk", { method: "POST", body: JSON.stringify({ action: "delete", turnIds }) });
|
|
1006
|
+
state.selectedArtifactTurns.clear();
|
|
1007
|
+
toast("Deleted " + (r.removed || []).length + " artifact turn(s)");
|
|
1008
|
+
loadArtifacts();
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
function highlightCode(text) {
|
|
1012
|
+
return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g, '<span class="chip">$1</span>');
|
|
1013
|
+
}
|
|
1014
|
+
async function previewArtifact(turnId, path) {
|
|
1015
|
+
const target = document.getElementById("artifactPreview");
|
|
1016
|
+
target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
|
|
1017
|
+
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
1018
|
+
try {
|
|
1019
|
+
const data = await api("/api/artifacts/preview?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path));
|
|
1020
|
+
if (data.kind === "image") {
|
|
1021
|
+
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="/api/artifacts/file?turnId=' + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path) + '"></div>';
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (data.kind === "text") {
|
|
1025
|
+
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + " " + fmtBytes(data.sizeBytes) + "</h2><pre>" + highlightCode(data.text || "") + "</pre>" + (data.truncated ? "<small>Preview truncated.</small>" : "") + "</div>";
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + "</h2><p>" + esc(data.detail || "Preview unavailable") + "</p></div>";
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
target.innerHTML = '<div class="panel"><h2>Preview failed</h2><p>' + esc(err.message || String(err)) + "</p></div>";
|
|
1031
|
+
throw err;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
async function loadActivity() {
|
|
1035
|
+
setLoading("activityList", "Loading activity...");
|
|
1036
|
+
const q = "?source=" + encodeURIComponent(val("activitySource")) + "&status=" + encodeURIComponent(val("activityStatus")) + "&limit=" + encodeURIComponent(val("activityLimit") || "100");
|
|
1037
|
+
const data = await api("/api/activity" + q);
|
|
1038
|
+
state.activityEvents = data.events || [];
|
|
1039
|
+
renderActivity(state.activityEvents);
|
|
1040
|
+
}
|
|
1041
|
+
function activityWorkspace(e) {
|
|
1042
|
+
const active = state.snapshot?.session;
|
|
1043
|
+
return e.workspace || (active?.threadId && e.threadId === active.threadId ? active.workspace : "");
|
|
1044
|
+
}
|
|
1045
|
+
function activityMetaHtml(e) {
|
|
1046
|
+
const workspace = activityWorkspace(e);
|
|
1047
|
+
const duration = typeof e.durationMs === "number" ? fmtDuration(e.durationMs) : "";
|
|
1048
|
+
const parts = [];
|
|
1049
|
+
if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
|
|
1050
|
+
if (workspace) parts.push(esc(workspace));
|
|
1051
|
+
if (duration) parts.push(esc(duration));
|
|
1052
|
+
return parts.join(" | ");
|
|
1053
|
+
}
|
|
1054
|
+
function renderActivity(events) {
|
|
1055
|
+
const since = val("activitySince") ? new Date(val("activitySince")).getTime() : 0;
|
|
1056
|
+
const filtered = (events || []).filter((e) => !since || new Date(e.timestamp).getTime() >= since);
|
|
1057
|
+
document.getElementById("activityList").innerHTML = filtered.map((e) => {
|
|
1058
|
+
const meta = activityMetaHtml(e);
|
|
1059
|
+
return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
|
|
1060
|
+
}).join("") || '<div class="item">No activity.</div>';
|
|
1061
|
+
document.querySelectorAll("#activityList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
|
|
1062
|
+
}
|
|
1063
|
+
document.getElementById("loadActivityBtn").onclick = () => loadActivity();
|
|
1064
|
+
document.getElementById("activitySince").onchange = () => renderActivity(state.activityEvents || []);
|
|
1065
|
+
document.getElementById("exportActivityBtn").onclick = () => {
|
|
1066
|
+
const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.status, e.type, e.threadId || "", e.prompt || e.detail || ""].join("\\t")).join("\\n");
|
|
1067
|
+
const blob = new Blob([rows], { type: "text/tab-separated-values" });
|
|
1068
|
+
const a = document.createElement("a");
|
|
1069
|
+
a.href = URL.createObjectURL(blob);
|
|
1070
|
+
a.download = "nordrelay-activity.tsv";
|
|
1071
|
+
a.click();
|
|
1072
|
+
URL.revokeObjectURL(a.href);
|
|
1073
|
+
};
|
|
1074
|
+
async function loadSettings() {
|
|
1075
|
+
setLoading("settingsForm", "Loading settings...");
|
|
1076
|
+
const data = await api("/api/settings");
|
|
1077
|
+
state.settings = data.settings;
|
|
1078
|
+
renderSettings();
|
|
1079
|
+
}
|
|
1080
|
+
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
|
|
1081
|
+
const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
|
|
1082
|
+
function orderedSettingsGroups(groups) {
|
|
1083
|
+
const known = settingsGroupOrder.filter((name) => groups[name]);
|
|
1084
|
+
const extra = Object.keys(groups).filter((name) => !settingsGroupOrder.includes(name)).sort();
|
|
1085
|
+
return known.concat(extra);
|
|
1086
|
+
}
|
|
1087
|
+
function agentSettingsNav(current) {
|
|
1088
|
+
return '<div class="agent-settings-nav"><strong>Agent settings</strong>' + agentSettingGroups.map((name) => '<button type="button" data-setting-tab="' + attr(name) + '" class="' + (name === current ? "active" : "") + '">' + esc(name) + "</button>").join("") + "</div>";
|
|
1089
|
+
}
|
|
1090
|
+
function renderSettings() {
|
|
1091
|
+
const groups = {};
|
|
1092
|
+
state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
|
|
1093
|
+
const names = orderedSettingsGroups(groups);
|
|
1094
|
+
if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" : names[0];
|
|
1095
|
+
document.getElementById("settingsTabs").innerHTML = names.map((name) => '<button data-setting-tab="' + attr(name) + '" class="' + (name === state.settingsGroup ? "active" : "") + '">' + esc(name) + " (" + groups[name].length + ")</button>").join("");
|
|
1096
|
+
document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
|
|
1097
|
+
state.settingsGroup = b.dataset.settingTab;
|
|
1098
|
+
renderSettings();
|
|
1099
|
+
});
|
|
1100
|
+
const items = groups[state.settingsGroup] || [];
|
|
1101
|
+
const nav = state.settingsGroup === "Agents" || agentSettingGroups.includes(state.settingsGroup) ? agentSettingsNav(state.settingsGroup) : "";
|
|
1102
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '"><label>' + esc(s.label) + "</label>" + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
|
|
1103
|
+
document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
|
|
1104
|
+
state.settingsGroup = b.dataset.settingTab;
|
|
1105
|
+
renderSettings();
|
|
1106
|
+
});
|
|
1107
|
+
bindSettingsUx();
|
|
1108
|
+
}
|
|
1109
|
+
function settingAttrs(s, original) {
|
|
1110
|
+
return ' data-setting="' + attr(s.key) + '" data-original-value="' + attr(original) + '" data-configured="' + (s.configured ? "true" : "false") + '"';
|
|
1111
|
+
}
|
|
1112
|
+
function settingInput(s) {
|
|
1113
|
+
const display = s.configured ? s.value || "" : s.effectiveValue || "";
|
|
1114
|
+
if (s.options) {
|
|
1115
|
+
const blankLabel = s.effectiveValue ? "Use active default (" + s.effectiveValue + ")" : "Use active default";
|
|
1116
|
+
return "<select" + settingAttrs(s, s.configured ? s.value : "") + '><option value="" ' + (!s.configured ? "selected" : "") + ">" + esc(blankLabel) + "</option>" + s.options.map((o) => '<option value="' + attr(o) + '" ' + (s.configured && s.value === o ? "selected" : "") + ">" + esc(o) + "</option>").join("") + "</select>";
|
|
1117
|
+
}
|
|
1118
|
+
if (s.kind === "boolean") {
|
|
1119
|
+
const blankLabel = s.effectiveValue ? "Use active default (" + s.effectiveValue + ")" : "Use active default";
|
|
1120
|
+
return "<select" + settingAttrs(s, s.configured ? s.value : "") + '><option value="" ' + (!s.configured ? "selected" : "") + ">" + esc(blankLabel) + '</option><option value="true" ' + (s.configured && s.value === "true" ? "selected" : "") + '>true</option><option value="false" ' + (s.configured && s.value === "false" ? "selected" : "") + ">false</option></select>";
|
|
1121
|
+
}
|
|
1122
|
+
const value = esc(display);
|
|
1123
|
+
if (s.kind === "json") return '<textarea rows="4"' + settingAttrs(s, display) + ">" + value + "</textarea>";
|
|
1124
|
+
return "<input" + settingAttrs(s, display) + ' value="' + value + '" ' + (s.kind === "secret" ? 'type="password"' : "") + ">";
|
|
1125
|
+
}
|
|
1126
|
+
function bindSettingsUx() {
|
|
1127
|
+
document.querySelectorAll("[data-setting]").forEach((el) => {
|
|
1128
|
+
el.oninput = markSettingDirty;
|
|
1129
|
+
el.onchange = markSettingDirty;
|
|
1130
|
+
});
|
|
1131
|
+
document.querySelectorAll("[data-reset-setting]").forEach((b) => b.onclick = () => {
|
|
1132
|
+
const input = document.querySelector('[data-setting="' + cssEscape(b.dataset.resetSetting) + '"]');
|
|
1133
|
+
if (input) {
|
|
1134
|
+
input.value = "";
|
|
1135
|
+
markSettingDirty({ target: input });
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
document.querySelectorAll("[data-reveal-setting]").forEach((b) => b.onclick = () => {
|
|
1139
|
+
const input = document.querySelector('[data-setting="' + cssEscape(b.dataset.revealSetting) + '"]');
|
|
1140
|
+
if (input) {
|
|
1141
|
+
input.type = input.type === "password" ? "text" : "password";
|
|
1142
|
+
input.focus();
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
function markSettingDirty(e) {
|
|
1147
|
+
const el = e.target;
|
|
1148
|
+
const box = el.closest(".setting");
|
|
1149
|
+
const dirty = el.value !== (el.dataset.originalValue ?? "");
|
|
1150
|
+
box.classList.toggle("dirty", dirty);
|
|
1151
|
+
const dirtyInputs = Array.from(document.querySelectorAll("[data-setting]")).filter((x) => x.value !== (x.dataset.originalValue ?? ""));
|
|
1152
|
+
const restart = dirtyInputs.some((x) => x.closest(".setting")?.dataset.restartRequired === "true");
|
|
1153
|
+
document.getElementById("settingsStatus").textContent = dirtyInputs.length ? dirtyInputs.length + " unsaved change(s)" : "";
|
|
1154
|
+
const banner = document.getElementById("settingsRestartBanner");
|
|
1155
|
+
if (banner) banner.innerHTML = restart ? '<div class="restart-banner">Some changed settings require a NordRelay restart.</div>' : "";
|
|
1156
|
+
}
|
|
1157
|
+
document.getElementById("saveSettingsBtn").onclick = () => safe(async () => {
|
|
1158
|
+
if (!can("settings.write")) {
|
|
1159
|
+
toast("Permission required: settings.write");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
document.querySelectorAll(".setting-error").forEach((e) => e.textContent = "");
|
|
1163
|
+
const patch = {};
|
|
1164
|
+
document.querySelectorAll("[data-setting]").forEach((el) => {
|
|
1165
|
+
const original = el.dataset.originalValue ?? "";
|
|
1166
|
+
if (el.value !== original) patch[el.dataset.setting] = el.value;
|
|
1167
|
+
});
|
|
1168
|
+
const r = await api("/api/settings", { method: "PATCH", body: JSON.stringify({ settings: patch }) });
|
|
1169
|
+
(r.errors || []).forEach((err) => {
|
|
1170
|
+
const box = document.querySelector('[data-setting-box="' + cssEscape(err.key) + '"] .setting-error');
|
|
1171
|
+
if (box) box.textContent = err.message;
|
|
1172
|
+
});
|
|
1173
|
+
document.getElementById("settingsStatus").textContent = r.errors && r.errors.length ? "Fix " + r.errors.length + " setting error(s)" : r.changedKeys.length ? "Saved " + r.changedKeys.length + " setting(s)" + (r.restartRequired ? " - restart required" : "") : "No changes";
|
|
1174
|
+
toast(r.errors && r.errors.length ? "Settings need attention" : "Settings saved");
|
|
1175
|
+
if (!(r.errors && r.errors.length)) await loadSettings();
|
|
1176
|
+
});
|
|
1177
|
+
document.getElementById("restartBtn").onclick = () => safe(async () => {
|
|
1178
|
+
if (!can("system.restart")) {
|
|
1179
|
+
toast("Permission required: system.restart");
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (confirm("Restart NordRelay now?")) {
|
|
1183
|
+
await api("/api/runtime/restart", { method: "POST" });
|
|
1184
|
+
toast("Restart requested");
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
async function loadAccess() {
|
|
1188
|
+
if (!can("users.read")) {
|
|
1189
|
+
document.getElementById("accessPanel").innerHTML = '<div class="item">Permission required: users.read</div>';
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
setLoading("accessPanel", "Loading users...");
|
|
1193
|
+
const d = await api("/api/users");
|
|
1194
|
+
state.userManagement = d;
|
|
1195
|
+
renderUserManagement(d);
|
|
1196
|
+
if (can("sessions.read")) await loadLocks();
|
|
1197
|
+
else document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
|
|
1198
|
+
if (can("audit.read")) await loadAudit();
|
|
1199
|
+
else document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
|
|
1200
|
+
}
|
|
1201
|
+
document.getElementById("loadAccessBtn").onclick = () => loadAccess();
|
|
1202
|
+
function groupOptions(selected = []) {
|
|
1203
|
+
const selectedSet = new Set(selected);
|
|
1204
|
+
return (state.userManagement?.groups || []).map((g) => '<label class="checkbox"><input type="checkbox" data-group-choice="' + attr(g.id) + '" ' + (selectedSet.has(g.id) ? "checked" : "") + "> " + esc(g.name) + "</label>").join("");
|
|
1205
|
+
}
|
|
1206
|
+
function permissionOptions(selected = []) {
|
|
1207
|
+
const selectedSet = new Set(selected);
|
|
1208
|
+
return '<div class="permission-grid full-span">' + (state.userManagement?.permissions || []).map((p) => '<label class="checkbox"><input type="checkbox" data-permission-choice="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</div>";
|
|
1209
|
+
}
|
|
1210
|
+
function selectedValues(selector) {
|
|
1211
|
+
return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.value).filter(Boolean);
|
|
1212
|
+
}
|
|
1213
|
+
function csv(values = []) {
|
|
1214
|
+
return (values || []).join(", ");
|
|
1215
|
+
}
|
|
1216
|
+
function adminDialog(title, body, onSubmit) {
|
|
1217
|
+
const dialog = document.getElementById("adminDialog");
|
|
1218
|
+
document.getElementById("adminDialogTitle").textContent = title;
|
|
1219
|
+
document.getElementById("adminDialogBody").innerHTML = body;
|
|
1220
|
+
document.getElementById("adminDialogCancel").onclick = () => dialog.close();
|
|
1221
|
+
document.getElementById("adminDialogForm").onsubmit = (e) => safe(async () => {
|
|
1222
|
+
e.preventDefault();
|
|
1223
|
+
await onSubmit();
|
|
1224
|
+
dialog.close();
|
|
1225
|
+
await loadAccess();
|
|
1226
|
+
}, e);
|
|
1227
|
+
dialog.showModal();
|
|
1228
|
+
}
|
|
1229
|
+
function userGroups(u) {
|
|
1230
|
+
return (u.groups || []).map((g) => g.id);
|
|
1231
|
+
}
|
|
1232
|
+
function renderUserManagement(d) {
|
|
1233
|
+
document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
|
|
1234
|
+
const telegram = (u.telegramIdentities || []).map((t) => t.telegramUserId + (t.username ? " @" + t.username : "") + ' <button class="secondary mini-button" data-telegram-user="' + attr(u.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button>").join(" ");
|
|
1235
|
+
return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
|
|
1236
|
+
}).join("") || '<div class="item">No users.</div>';
|
|
1237
|
+
document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
|
|
1238
|
+
document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + "</span></strong><small>" + esc("Chat ID: " + c.chatId + " / " + (c.type || "-")) + "</small><small>Groups: " + esc((c.allowedGroupIds || []).join(", ") || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
|
|
1239
|
+
bindUserButtons();
|
|
1240
|
+
applyPermissions();
|
|
1241
|
+
}
|
|
1242
|
+
function bindUserButtons() {
|
|
1243
|
+
document.querySelectorAll("[data-user-edit]").forEach((b) => b.onclick = () => {
|
|
1244
|
+
const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userEdit);
|
|
1245
|
+
if (u) openUserDialog(u);
|
|
1246
|
+
});
|
|
1247
|
+
document.querySelectorAll("[data-user-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1248
|
+
const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userToggle);
|
|
1249
|
+
if (!u) return;
|
|
1250
|
+
await api("/api/users/" + encodeURIComponent(u.id), { method: "PATCH", body: JSON.stringify({ active: !u.active }) });
|
|
1251
|
+
toast("User updated");
|
|
1252
|
+
loadAccess();
|
|
1253
|
+
}));
|
|
1254
|
+
document.querySelectorAll("[data-user-code]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1255
|
+
const r = await api("/api/users/" + encodeURIComponent(b.dataset.userCode) + "/telegram", { method: "POST", body: JSON.stringify({ createCode: true }) });
|
|
1256
|
+
toast("Telegram link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
|
|
1257
|
+
}));
|
|
1258
|
+
document.querySelectorAll("[data-user-link]").forEach((b) => b.onclick = () => openTelegramLinkDialog(b.dataset.userLink));
|
|
1259
|
+
document.querySelectorAll("[data-user-password]").forEach((b) => b.onclick = () => openPasswordDialog(b.dataset.userPassword));
|
|
1260
|
+
document.querySelectorAll("[data-user-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1261
|
+
if (confirm("Revoke all web sessions for this user?")) {
|
|
1262
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.userRevoke) + "/sessions", { method: "DELETE" });
|
|
1263
|
+
toast("Sessions revoked");
|
|
1264
|
+
loadAccess();
|
|
1265
|
+
}
|
|
1266
|
+
}));
|
|
1267
|
+
document.querySelectorAll("[data-telegram-unlink]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1268
|
+
if (confirm("Unlink this Telegram identity?")) {
|
|
1269
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.telegramUser) + "/telegram/" + encodeURIComponent(b.dataset.telegramUnlink), { method: "DELETE" });
|
|
1270
|
+
toast("Telegram unlinked");
|
|
1271
|
+
loadAccess();
|
|
1272
|
+
}
|
|
1273
|
+
}));
|
|
1274
|
+
document.querySelectorAll("[data-group-edit]").forEach((b) => b.onclick = () => {
|
|
1275
|
+
const g = (state.userManagement?.groups || []).find((x) => x.id === b.dataset.groupEdit);
|
|
1276
|
+
if (g) openGroupDialog(g);
|
|
1277
|
+
});
|
|
1278
|
+
document.querySelectorAll("[data-chat-edit]").forEach((b) => b.onclick = () => {
|
|
1279
|
+
const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatEdit);
|
|
1280
|
+
if (c) openChatDialog(c);
|
|
1281
|
+
});
|
|
1282
|
+
document.querySelectorAll("[data-chat-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1283
|
+
const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatToggle);
|
|
1284
|
+
if (!c) return;
|
|
1285
|
+
await api("/api/telegram-chats/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
|
|
1286
|
+
toast("Chat updated");
|
|
1287
|
+
loadAccess();
|
|
1288
|
+
}));
|
|
1289
|
+
}
|
|
1290
|
+
function openUserDialog(u) {
|
|
1291
|
+
adminDialog(u ? "Edit user" : "Create user", '<label>Email<input id="dlgEmail" value="' + attr(u?.email || "") + '"></label><label>Display name<input id="dlgName" value="' + attr(u?.displayName || "") + '"></label>' + (!u ? '<label>Password<input id="dlgPassword" type="password" autocomplete="new-password"></label>' : "") + '<label class="checkbox"><input id="dlgActive" type="checkbox" ' + (!u || u.active ? "checked" : "") + '> Active</label><div class="full-span"><strong>Groups</strong><div class="row">' + groupOptions(u ? userGroups(u) : ["user"]) + "</div></div>", async () => {
|
|
1292
|
+
const payload = { email: val("dlgEmail"), displayName: val("dlgName"), active: document.getElementById("dlgActive").checked, groupIds: selectedValues("[data-group-choice]") };
|
|
1293
|
+
if (!u) payload.password = val("dlgPassword");
|
|
1294
|
+
await api(u ? "/api/users/" + encodeURIComponent(u.id) : "/api/users", { method: u ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1295
|
+
toast(u ? "User updated" : "User created");
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
function openPasswordDialog(id) {
|
|
1299
|
+
adminDialog("Set password", '<label class="full-span">New password<input id="dlgPassword" type="password" autocomplete="new-password"></label>', async () => {
|
|
1300
|
+
await api("/api/users/" + encodeURIComponent(id) + "/password", { method: "POST", body: JSON.stringify({ password: val("dlgPassword") }) });
|
|
1301
|
+
toast("Password updated");
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
function openTelegramLinkDialog(id) {
|
|
1305
|
+
adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
|
|
1306
|
+
await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
|
|
1307
|
+
toast("Telegram linked");
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
function openGroupDialog(g) {
|
|
1311
|
+
adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
|
|
1312
|
+
const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger) };
|
|
1313
|
+
await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1314
|
+
toast(g ? "Group updated" : "Group created");
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
function openChatDialog(c) {
|
|
1318
|
+
adminDialog(c ? "Edit Telegram chat" : "Add Telegram chat", '<label>Chat ID<input id="dlgChatId" type="number" value="' + attr(c?.chatId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgChatTitle" value="' + attr(c?.title || "") + '"></label><label>Type<input id="dlgChatType" value="' + attr(c?.type || "supergroup") + '"></label><label class="checkbox"><input id="dlgChatEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
|
|
1319
|
+
const payload = { chatId: Number(val("dlgChatId")), title: val("dlgChatTitle") || void 0, type: val("dlgChatType") || void 0, enabled: document.getElementById("dlgChatEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
|
|
1320
|
+
await api(c ? "/api/telegram-chats/" + encodeURIComponent(c.id) : "/api/telegram-chats", { method: c ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1321
|
+
toast(c ? "Chat updated" : "Chat added");
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
function csvToList(text) {
|
|
1325
|
+
return (text || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
1326
|
+
}
|
|
1327
|
+
document.getElementById("createUserBtn").onclick = () => {
|
|
1328
|
+
if (!can("users.write")) {
|
|
1329
|
+
toast("Permission required: users.write");
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
openUserDialog(null);
|
|
1333
|
+
};
|
|
1334
|
+
document.getElementById("createGroupBtn").onclick = () => {
|
|
1335
|
+
if (!can("users.write")) {
|
|
1336
|
+
toast("Permission required: users.write");
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
openGroupDialog(null);
|
|
1340
|
+
};
|
|
1341
|
+
document.getElementById("createChatBtn").onclick = () => {
|
|
1342
|
+
if (!can("users.write")) {
|
|
1343
|
+
toast("Permission required: users.write");
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
openChatDialog(null);
|
|
1347
|
+
};
|
|
1348
|
+
async function loadLocks() {
|
|
1349
|
+
if (!can("sessions.read")) {
|
|
1350
|
+
document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const d = await api("/api/locks");
|
|
1354
|
+
document.getElementById("locksList").innerHTML = (d.locks || []).map((l) => '<div class="item"><strong>' + esc(l.contextKey) + "</strong><small>" + esc((l.ownerName || "owner") + " / " + l.ownerId + " / expires " + fmtDate(l.expiresAt)) + "</small></div>").join("") || '<div class="item">No active locks.</div>';
|
|
1355
|
+
}
|
|
1356
|
+
document.getElementById("lockSessionBtn").onclick = () => safe(async () => {
|
|
1357
|
+
if (!can("sessions.write")) {
|
|
1358
|
+
toast("Permission required: sessions.write");
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
await api("/api/locks", { method: "POST", body: JSON.stringify({ ownerName: "Web dashboard" }) });
|
|
1362
|
+
toast("Web session locked");
|
|
1363
|
+
loadLocks();
|
|
1364
|
+
});
|
|
1365
|
+
document.getElementById("unlockSessionBtn").onclick = () => safe(async () => {
|
|
1366
|
+
if (!can("sessions.write")) {
|
|
1367
|
+
toast("Permission required: sessions.write");
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
await api("/api/locks", { method: "DELETE" });
|
|
1371
|
+
toast("Web session unlocked");
|
|
1372
|
+
loadLocks();
|
|
1373
|
+
});
|
|
1374
|
+
async function loadAudit() {
|
|
1375
|
+
if (!can("audit.read")) {
|
|
1376
|
+
document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
const d = await api("/api/audit?limit=" + encodeURIComponent(val("auditLimit") || "50"));
|
|
1380
|
+
document.getElementById("auditList").innerHTML = (d.events || []).map((e) => '<div class="item"><strong>' + esc(fmtDate(e.timestamp) + " / " + (e.channelId || "-") + " / " + e.status + " / " + e.action) + "</strong><small>" + esc((e.contextKey || "-") + " / " + (e.agentId || "-") + " / " + (e.threadId || "-")) + "</small><small>" + esc(e.description || e.detail || "") + "</small></div>").join("") || '<div class="item">No audit events.</div>';
|
|
1381
|
+
}
|
|
1382
|
+
document.getElementById("loadAuditBtn").onclick = () => loadAudit();
|
|
1383
|
+
async function loadLogs() {
|
|
1384
|
+
if (!document.getElementById("logAutoRefresh").checked) setLoading("logs", "Loading logs...");
|
|
1385
|
+
const target = document.getElementById("logTarget").value;
|
|
1386
|
+
const lines = document.getElementById("logLines").value;
|
|
1387
|
+
const data = await api("/api/logs?target=" + target + "&lines=" + lines);
|
|
1388
|
+
state.logsPlain = data.plain || "";
|
|
1389
|
+
renderLogs();
|
|
1390
|
+
if (document.getElementById("logFollow").checked) document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
|
|
1391
|
+
}
|
|
1392
|
+
document.getElementById("loadLogsBtn").onclick = loadLogs;
|
|
1393
|
+
function logLevelOf(line) {
|
|
1394
|
+
if (line.includes(" ERROR ")) return "ERROR";
|
|
1395
|
+
if (line.includes(" WARN ")) return "WARN";
|
|
1396
|
+
if (line.includes(" INFO ")) return "INFO";
|
|
1397
|
+
return "";
|
|
1398
|
+
}
|
|
1399
|
+
function logTimeOf(line) {
|
|
1400
|
+
const m = line.match(/^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})/);
|
|
1401
|
+
return m ? new Date(m[1].replace(" ", "T")).getTime() : 0;
|
|
1402
|
+
}
|
|
1403
|
+
function renderLogs() {
|
|
1404
|
+
const level = val("logLevel");
|
|
1405
|
+
const query = val("logSearch").toLowerCase();
|
|
1406
|
+
const since = val("logSince") ? new Date(val("logSince")).getTime() : 0;
|
|
1407
|
+
const lines = state.logsPlain.split(/\\n/).filter((line) => line.length > 0 && (level === "all" || line.includes(level)) && (!query || line.toLowerCase().includes(query)) && (!since || !logTimeOf(line) || logTimeOf(line) >= since));
|
|
1408
|
+
document.getElementById("logs").innerHTML = lines.map((line) => '<span class="log-line ' + logLevelOf(line) + '">' + esc(line) + "</span>").join("") || "(empty)";
|
|
1409
|
+
}
|
|
1410
|
+
document.getElementById("logLevel").onchange = renderLogs;
|
|
1411
|
+
document.getElementById("logSearch").oninput = renderLogs;
|
|
1412
|
+
document.getElementById("logSince").onchange = renderLogs;
|
|
1413
|
+
document.getElementById("logAutoRefresh").onchange = (e) => {
|
|
1414
|
+
clearInterval(state.logTimer);
|
|
1415
|
+
state.logTimer = null;
|
|
1416
|
+
if (e.target.checked) state.logTimer = setInterval(loadLogs, 5e3);
|
|
1417
|
+
};
|
|
1418
|
+
document.getElementById("downloadLogsBtn").onclick = () => {
|
|
1419
|
+
const blob = new Blob([state.logsPlain || ""], { type: "text/plain" });
|
|
1420
|
+
const a = document.createElement("a");
|
|
1421
|
+
a.href = URL.createObjectURL(blob);
|
|
1422
|
+
a.download = "nordrelay-log.txt";
|
|
1423
|
+
a.click();
|
|
1424
|
+
URL.revokeObjectURL(a.href);
|
|
1425
|
+
};
|
|
1426
|
+
document.getElementById("clearLogsBtn").onclick = () => safe(async () => {
|
|
1427
|
+
if (!can("logs.clear")) {
|
|
1428
|
+
toast("Permission required: logs.clear");
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
const target = document.getElementById("logTarget").value;
|
|
1432
|
+
if (confirm("Clear " + target + " log?")) {
|
|
1433
|
+
await api("/api/logs/clear", { method: "POST", body: JSON.stringify({ target }) });
|
|
1434
|
+
state.logsPlain = "";
|
|
1435
|
+
renderLogs();
|
|
1436
|
+
toast("Cleared " + target + " log");
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
async function loadAdapterHealth() {
|
|
1440
|
+
setLoading("adapterHealth", "Loading adapters...");
|
|
1441
|
+
const d = await api("/api/adapters/health");
|
|
1442
|
+
document.getElementById("adapterHealth").innerHTML = (d.adapters || []).map((a) => '<div class="item"><strong>' + esc(a.label) + ' <span class="adapter-status ' + esc(a.status) + '">' + esc(a.status) + "</span></strong><small>" + esc("CLI: " + (a.cli.label || "-") + " / path " + (a.cli.path || "-") + " / version " + (a.cli.version || "-")) + "</small><small>" + esc("Auth: " + (a.auth.supported ? a.auth.authenticated ? "authenticated" : "not authenticated" : "not managed") + " " + (a.auth.detail || "")) + "</small><small>" + esc("Version: " + a.version.installed + " / latest " + (a.version.latest || "-") + " / " + a.version.status) + "</small>" + featureMatrix(a.capabilities) + '<div class="row"><button data-auth-status="' + attr(a.id) + '">Auth status</button><button data-auth-login="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.login ? "disabled" : "") + disabledAttr("auth.manage") + '>Login</button><button data-auth-logout="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.logout ? "disabled" : "") + disabledAttr("auth.manage") + ">Logout</button></div></div>").join("") || '<div class="item">No adapters.</div>';
|
|
1443
|
+
document.querySelectorAll("[data-auth-status]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1444
|
+
const r = await api("/api/auth/status?agent=" + encodeURIComponent(b.dataset.authStatus));
|
|
1445
|
+
toast(r.agentLabel + ": " + r.detail, { duration: 6e3 });
|
|
1446
|
+
}));
|
|
1447
|
+
document.querySelectorAll("[data-auth-login]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1448
|
+
if (!can("auth.manage")) {
|
|
1449
|
+
toast("Permission required: auth.manage");
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const r = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ agentId: b.dataset.authLogin }) });
|
|
1453
|
+
toast(r.result?.message || r.detail, { duration: 8e3 });
|
|
1454
|
+
loadAdapterHealth();
|
|
1455
|
+
}));
|
|
1456
|
+
document.querySelectorAll("[data-auth-logout]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1457
|
+
if (!can("auth.manage")) {
|
|
1458
|
+
toast("Permission required: auth.manage");
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
const r = await api("/api/auth/logout", { method: "POST", body: JSON.stringify({ agentId: b.dataset.authLogout }) });
|
|
1462
|
+
toast(r.result?.message || r.detail, { duration: 8e3 });
|
|
1463
|
+
loadAdapterHealth();
|
|
1464
|
+
}));
|
|
1465
|
+
applyPermissions();
|
|
1466
|
+
}
|
|
1467
|
+
document.getElementById("reloadAdaptersBtn").onclick = () => loadAdapterHealth();
|
|
1468
|
+
const versionAgentIds = { codex: "codex", pi: "pi", hermes: "hermes", openclaw: "openclaw", claudeCode: "claude-code" };
|
|
1469
|
+
async function loadVersion() {
|
|
1470
|
+
setLoading("versionPanel", "Checking versions...");
|
|
1471
|
+
if (can("updates.run")) await loadAgentUpdateJobs(false);
|
|
1472
|
+
else {
|
|
1473
|
+
state.agentUpdateJobs = [];
|
|
1474
|
+
renderAgentUpdateJobs();
|
|
1475
|
+
}
|
|
1476
|
+
const d = await api("/api/version");
|
|
1477
|
+
const checks = d.versionChecks || {};
|
|
1478
|
+
document.getElementById("versionPanel").innerHTML = Object.entries(checks).map(([key, v]) => versionCard(key, v)).join("") + card("Runtime", [["Status", d.state?.status], ["Version", d.health?.version], ["Codex CLI", d.health?.codexCli], ["Pi CLI", d.health?.piCli], ["Hermes CLI", d.health?.hermesCli], ["OpenClaw CLI", d.health?.openClawCli], ["Claude Code CLI", d.health?.claudeCodeCli]]);
|
|
1479
|
+
bindAgentUpdateButtons();
|
|
1480
|
+
applyPermissions();
|
|
1481
|
+
}
|
|
1482
|
+
function versionCard(key, v) {
|
|
1483
|
+
const agentId = versionAgentIds[key];
|
|
1484
|
+
const running = agentId && state.agentUpdateJobs.some((j) => j.agentId === agentId && j.status === "running");
|
|
1485
|
+
const button = agentId && v.status === "outdated" ? '<button class="secondary mini-button" data-update-agent="' + attr(agentId) + '" ' + (running ? "disabled" : "") + ">" + (running ? "Updating" : "Update") + "</button>" : "";
|
|
1486
|
+
return '<div class="item"><strong>' + esc(v.label) + ' <span class="adapter-status ' + esc(versionStatusClass(v.status)) + '">' + esc(versionStatusLabel(v.status)) + "</span> " + button + "</strong><small>" + esc("Installed: " + (v.installedLabel || "-")) + "</small><small>" + esc("Latest: " + (v.latestVersion || "-")) + "</small>" + (v.detail ? "<small>" + esc(v.detail) + "</small>" : "") + "</div>";
|
|
1487
|
+
}
|
|
1488
|
+
async function loadAgentUpdateJobs(showLoading = true) {
|
|
1489
|
+
if (!can("updates.run")) {
|
|
1490
|
+
state.agentUpdateJobs = [];
|
|
1491
|
+
const target = document.getElementById("agentUpdateJobs");
|
|
1492
|
+
if (target) target.innerHTML = '<div class="item">Permission required: updates.run</div>';
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (showLoading) setLoading("agentUpdateJobs", "Loading update jobs...");
|
|
1496
|
+
const d = await api("/api/agent-updates");
|
|
1497
|
+
state.agentUpdateJobs = d.jobs || [];
|
|
1498
|
+
renderAgentUpdateJobs();
|
|
1499
|
+
}
|
|
1500
|
+
function upsertAgentUpdateJob(job) {
|
|
1501
|
+
if (!job) return;
|
|
1502
|
+
const index = state.agentUpdateJobs.findIndex((j) => j.id === job.id);
|
|
1503
|
+
if (index >= 0) state.agentUpdateJobs[index] = job;
|
|
1504
|
+
else state.agentUpdateJobs.unshift(job);
|
|
1505
|
+
}
|
|
1506
|
+
function renderAgentUpdateJobs() {
|
|
1507
|
+
const target = document.getElementById("agentUpdateJobs");
|
|
1508
|
+
if (!target) return;
|
|
1509
|
+
if (!can("updates.run")) {
|
|
1510
|
+
target.innerHTML = '<div class="item">Permission required: updates.run</div>';
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
target.innerHTML = (state.agentUpdateJobs || []).map(updateJobCard).join("") || '<div class="item">No agent update jobs.</div>';
|
|
1514
|
+
bindAgentUpdateButtons();
|
|
1515
|
+
applyPermissions();
|
|
1516
|
+
}
|
|
1517
|
+
function updateJobCard(job) {
|
|
1518
|
+
const command = [job.command].concat(job.args || []).join(" ");
|
|
1519
|
+
const needs = job.needsInput ? '<small><span class="chip warn">Input may be required</span></small>' : "";
|
|
1520
|
+
const logDeleted = job.logDeletedAt ? "<small>Log deleted " + esc(fmtDate(job.logDeletedAt)) + "</small>" : "";
|
|
1521
|
+
const deleteLogButton = job.status !== "running" && !job.logDeletedAt ? '<button class="danger mini-button" data-update-delete-log="' + attr(job.id) + '"' + disabledAttr("updates.run") + ">Delete Log</button>" : "";
|
|
1522
|
+
const input = job.canInput ? '<div class="update-input"><input data-update-input="' + attr(job.id) + '" placeholder="Send response to update process"><button data-update-send="' + attr(job.id) + '" class="secondary"' + disabledAttr("updates.run") + '>Send</button><button data-update-cancel="' + attr(job.id) + '" class="danger"' + disabledAttr("updates.run") + ">Cancel</button></div>" : "";
|
|
1523
|
+
return '<div class="item"><div class="update-job-header"><strong>' + esc(job.agentLabel) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + '</span></strong><div class="row">' + deleteLogButton + "</div></div><small>" + esc(job.method + " / " + fmtDate(job.startedAt) + (job.finishedAt ? " - " + fmtDate(job.finishedAt) : "")) + "</small><small>" + esc(command) + "</small><small>" + esc(job.error || job.summary || "") + "</small>" + logDeleted + needs + '<pre class="update-log">' + esc(job.outputTail || (job.logDeletedAt ? "(log deleted)" : "(waiting for output)")) + "</pre>" + input + "</div>";
|
|
1524
|
+
}
|
|
1525
|
+
function bindAgentUpdateButtons() {
|
|
1526
|
+
document.querySelectorAll("[data-update-agent]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1527
|
+
if (!can("updates.run")) {
|
|
1528
|
+
toast("Permission required: updates.run");
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (confirm("Start update for " + b.dataset.updateAgent + "?")) {
|
|
1532
|
+
const r = await api("/api/agent-update", { method: "POST", body: JSON.stringify({ agentId: b.dataset.updateAgent }) });
|
|
1533
|
+
upsertAgentUpdateJob(r.job);
|
|
1534
|
+
renderAgentUpdateJobs();
|
|
1535
|
+
toast(r.job.agentLabel + " update started", { duration: 6e3 });
|
|
1536
|
+
loadVersion();
|
|
1537
|
+
}
|
|
1538
|
+
}));
|
|
1539
|
+
document.querySelectorAll("[data-update-send]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1540
|
+
if (!can("updates.run")) {
|
|
1541
|
+
toast("Permission required: updates.run");
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
const input = document.querySelector('[data-update-input="' + cssEscape(b.dataset.updateSend) + '"]');
|
|
1545
|
+
const text = input?.value || "";
|
|
1546
|
+
if (!text.trim()) return;
|
|
1547
|
+
const r = await api("/api/agent-update/" + encodeURIComponent(b.dataset.updateSend) + "/input", { method: "POST", body: JSON.stringify({ input: text }) });
|
|
1548
|
+
if (input) input.value = "";
|
|
1549
|
+
upsertAgentUpdateJob(r.job);
|
|
1550
|
+
renderAgentUpdateJobs();
|
|
1551
|
+
}));
|
|
1552
|
+
document.querySelectorAll("[data-update-cancel]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1553
|
+
if (!can("updates.run")) {
|
|
1554
|
+
toast("Permission required: updates.run");
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (confirm("Cancel this update job?")) {
|
|
1558
|
+
const r = await api("/api/agent-update/" + encodeURIComponent(b.dataset.updateCancel) + "/cancel", { method: "POST" });
|
|
1559
|
+
upsertAgentUpdateJob(r.job);
|
|
1560
|
+
renderAgentUpdateJobs();
|
|
1561
|
+
}
|
|
1562
|
+
}));
|
|
1563
|
+
document.querySelectorAll("[data-update-delete-log]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1564
|
+
if (!can("updates.run")) {
|
|
1565
|
+
toast("Permission required: updates.run");
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
if (confirm("Delete update log for " + b.dataset.updateDeleteLog + "?")) {
|
|
1569
|
+
const r = await api("/api/agent-update/" + encodeURIComponent(b.dataset.updateDeleteLog) + "/log", { method: "DELETE" });
|
|
1570
|
+
state.agentUpdateJobs = (state.agentUpdateJobs || []).filter((j) => j.id !== (r.deletedId || b.dataset.updateDeleteLog));
|
|
1571
|
+
renderAgentUpdateJobs();
|
|
1572
|
+
toast("Agent update job deleted");
|
|
1573
|
+
}
|
|
1574
|
+
}));
|
|
1575
|
+
}
|
|
1576
|
+
document.getElementById("loadVersionBtn").onclick = () => loadVersion();
|
|
1577
|
+
document.getElementById("updateBtn").onclick = () => safe(async () => {
|
|
1578
|
+
if (!can("updates.run")) {
|
|
1579
|
+
toast("Permission required: updates.run");
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
if (confirm("Start NordRelay self-update now?")) {
|
|
1583
|
+
const r = await api("/api/update", { method: "POST" });
|
|
1584
|
+
toast("Update started via " + r.method + ". Log: " + r.logPath, { duration: 8e3 });
|
|
1585
|
+
page("logs");
|
|
1586
|
+
document.getElementById("logTarget").value = "update";
|
|
1587
|
+
loadLogs();
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
async function loadDiagnostics() {
|
|
1591
|
+
setLoading("diagnostics", "Loading diagnostics...");
|
|
1592
|
+
const data = await api("/api/diagnostics");
|
|
1593
|
+
document.getElementById("diagnostics").innerHTML = diagnosticsHtml(data);
|
|
1594
|
+
}
|
|
1595
|
+
function diagnosticsHtml(d) {
|
|
1596
|
+
const h = d.health || {};
|
|
1597
|
+
const s = d.snapshot?.session || {};
|
|
1598
|
+
const vc = d.versionChecks || {};
|
|
1599
|
+
const caps = s.capabilities || {};
|
|
1600
|
+
const agentDiag = d.runtime?.agentDiagnostics;
|
|
1601
|
+
return '<div class="list">' + card("Runtime", [["Status", h.state?.status], ["PID", h.state?.pid], ["App PID", h.state?.appPid], ["State", h.stateFile], ["Log", h.logFile], ["State backend", d.runtime?.stateBackend], ["Uptime", h.uptimeSeconds + "s"]]) + card("Agent", [["Agent", s.agentLabel], ["Thread", s.threadId], ["Workspace", s.workspace], ["Model", s.model], ["Reasoning", s.reasoningEffort], ["Fast", caps.fastMode ? s.fastMode ? "on" : "off" : "n/a"]]) + card("Agent State", (agentDiag?.lines || []).map((x) => [x.label, x.value])) + card("CLI Versions", Object.values(vc).map((v) => [v.label, (v.status === "current" ? "OK " : "WARN ") + (v.installedLabel || "-") + " latest " + (v.latestVersion || "-")])) + card("External Mirror", d.runtime?.externalMirror ? Object.entries(d.runtime.externalMirror) : [["Status", "idle"]]) + "</div>";
|
|
1602
|
+
}
|
|
1603
|
+
function card(title, rows) {
|
|
1604
|
+
return '<div class="item"><strong>' + esc(title) + "</strong>" + rows.map((r) => "<small>" + esc(r[0]) + ": " + esc(r[1] ?? "-") + "</small>").join("") + "</div>";
|
|
1605
|
+
}
|
|
1606
|
+
function safe(fn, event) {
|
|
1607
|
+
if (event && event.preventDefault) event.preventDefault();
|
|
1608
|
+
Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
|
|
1609
|
+
}
|
|
1610
|
+
loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
|
|
1611
|
+
})();
|