@nordbyte/nordrelay 0.8.1 → 0.8.2
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 +9 -0
- package/README.md +81 -1206
- package/dist/{access-control.js → access/access-control.js} +1 -1
- package/dist/{audit-log.js → access/audit-log.js} +2 -2
- package/dist/{session-locks.js → access/session-locks.js} +1 -1
- package/dist/{user-management.js → access/user-management.js} +1 -1
- package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
- package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
- package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
- package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
- package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
- package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
- package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
- package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
- package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
- package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
- package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
- package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
- package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
- package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
- package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
- package/dist/agents/shared/agent-auth-commands.js +30 -0
- package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
- package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
- package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
- package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
- package/dist/{discord-bot.js → channels/discord/discord-bot.js} +164 -424
- package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
- package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
- package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
- package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
- package/dist/channels/shared/channel-bridge-controller.js +69 -0
- package/dist/channels/shared/channel-cli-artifacts.js +51 -0
- package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
- package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
- package/dist/channels/shared/channel-external-monitor.js +52 -0
- package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
- package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
- package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +2 -2
- package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
- package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
- package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
- package/dist/{slack-bot.js → channels/slack/slack-bot.js} +159 -294
- package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
- package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
- package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
- package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
- package/dist/{bot.js → channels/telegram/bot.js} +178 -427
- package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
- package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
- package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
- package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
- package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
- package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
- package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
- package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
- package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
- package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
- package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
- package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
- package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
- package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
- package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
- package/dist/{config.js → core/config.js} +11 -3
- package/dist/index.js +27 -23
- package/dist/peers/peer-discovery-jobs.js +206 -0
- package/dist/peers/peer-discovery.js +223 -0
- package/dist/peers/peer-health-monitor.js +49 -0
- package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
- package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
- package/dist/{peer-server.js → peers/peer-server.js} +3 -2
- package/dist/{peer-store.js → peers/peer-store.js} +80 -9
- package/dist/{peer-types.js → peers/peer-types.js} +9 -0
- package/dist/peers/peer-web-proxy-contract.js +127 -0
- package/dist/{metrics.js → runtime/metrics.js} +5 -3
- package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
- package/dist/runtime/relay-auth-service.js +63 -0
- package/dist/runtime/relay-dashboard-service.js +139 -0
- package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
- package/dist/runtime/relay-runtime-active-sessions.js +387 -0
- package/dist/runtime/relay-runtime-dashboard.js +201 -0
- package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
- package/dist/runtime/relay-runtime-sessions.js +623 -0
- package/dist/runtime/relay-runtime-types.js +1 -0
- package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
- package/dist/runtime/relay-runtime.js +451 -0
- package/dist/runtime/runtime-cache.js +117 -0
- package/dist/{session-registry.js → state/session-registry.js} +3 -3
- package/dist/{operations.js → support/operations.js} +7 -7
- package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
- package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
- package/dist/web/web-api-types.js +1 -0
- package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
- package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
- package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
- package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
- package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
- package/dist/web/web-dashboard-security.js +14 -0
- package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
- package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
- package/dist/web/web-performance.js +60 -0
- package/dist/web/web-rate-limit.js +19 -0
- package/dist/{web-state.js → web/web-state.js} +74 -5
- package/dist/webui-assets/dashboard.css +171 -10
- package/dist/webui-assets/dashboard.js +514 -48
- package/dist/webui-assets/favicon.ico +0 -0
- package/dist/webui-assets/favicon.png +0 -0
- package/dist/webui-assets/logo.png +0 -0
- package/package.json +4 -3
- package/plugins/nordrelay/scripts/nordrelay.mjs +13 -4
- package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
- package/dist/relay-runtime.js +0 -1916
- package/dist/runtime-cache.js +0 -57
- /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
- /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
- /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
- /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
- /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
- /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
- /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
- /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
- /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
- /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
- /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
- /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
- /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
- /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
- /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
- /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
- /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
- /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
- /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
- /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
- /package/dist/{agent.js → agents/shared/agent.js} +0 -0
- /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
- /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
- /package/dist/{voice.js → artifacts/voice.js} +0 -0
- /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
- /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
- /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
- /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
- /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
- /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
- /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
- /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
- /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
- /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
- /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
- /package/dist/{activity-events.js → core/activity-events.js} +0 -0
- /package/dist/{error-messages.js → core/error-messages.js} +0 -0
- /package/dist/{format.js → core/format.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{redaction.js → core/redaction.js} +0 -0
- /package/dist/{settings-service.js → core/settings-service.js} +0 -0
- /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
- /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
- /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
- /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
- /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
- /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
- /package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +0 -0
- /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
- /package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +0 -0
- /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
- /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
- /package/dist/{job-store.js → state/job-store.js} +0 -0
- /package/dist/{persistence.js → state/persistence.js} +0 -0
- /package/dist/{prompt-store.js → state/prompt-store.js} +0 -0
- /package/dist/{state-backend.js → state/state-backend.js} +0 -0
- /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
- /package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +0 -0
- /package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +0 -0
- /package/dist/{web-dashboard-ui.js → web/web-dashboard-ui.js} +0 -0
|
@@ -25,8 +25,16 @@
|
|
|
25
25
|
{ path: "/api/peers/invite", methods: ["POST"] },
|
|
26
26
|
{ path: "/api/peers/pair", methods: ["POST"] },
|
|
27
27
|
{ path: "/api/peers/probe", methods: ["POST"] },
|
|
28
|
+
{ path: "/api/peers/discover", methods: ["GET"] },
|
|
29
|
+
{ path: "/api/peers/discovery-jobs", methods: ["GET", "POST"] },
|
|
30
|
+
{ re: /^\/api\/peers\/discovery-jobs\/[^\/]+$/, methods: ["GET"] },
|
|
31
|
+
{ re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/cancel$/, methods: ["POST"] },
|
|
32
|
+
{ re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/log$/, methods: ["GET"] },
|
|
33
|
+
{ path: "/api/peers/identity/backup", methods: ["GET"] },
|
|
34
|
+
{ path: "/api/peers/identity/restore", methods: ["POST"] },
|
|
28
35
|
{ path: "/api/peers/global-sessions", methods: ["GET"] },
|
|
29
36
|
{ re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
|
|
37
|
+
{ re: /^\/api\/peers\/[^\/]+\/repin$/, methods: ["POST"] },
|
|
30
38
|
{ re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
|
|
31
39
|
{ re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
|
|
32
40
|
{ re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
|
|
@@ -79,6 +87,7 @@
|
|
|
79
87
|
{ path: "/api/sync", methods: ["POST"] },
|
|
80
88
|
{ path: "/api/queue", methods: ["GET", "POST"] },
|
|
81
89
|
{ path: "/api/chat/history", methods: ["GET", "DELETE"] },
|
|
90
|
+
{ path: "/api/chat/mirror", methods: ["GET", "POST"] },
|
|
82
91
|
{ path: "/api/activity", methods: ["GET"] },
|
|
83
92
|
{ path: "/api/artifacts", methods: ["GET", "DELETE"] },
|
|
84
93
|
{ path: "/api/artifacts/bulk", methods: ["POST"] },
|
|
@@ -102,6 +111,10 @@
|
|
|
102
111
|
assertApiRoute(url.pathname, method);
|
|
103
112
|
if (!options.local && shouldProxyApi(url.pathname)) {
|
|
104
113
|
const peerId = selectedPeerTarget();
|
|
114
|
+
const csrfToken2 = (
|
|
115
|
+
/** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
|
|
116
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
|
|
117
|
+
);
|
|
105
118
|
const proxyBody = JSON.stringify({
|
|
106
119
|
method,
|
|
107
120
|
path: url.pathname,
|
|
@@ -111,7 +124,7 @@
|
|
|
111
124
|
});
|
|
112
125
|
const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
|
|
113
126
|
method: "POST",
|
|
114
|
-
headers: { "content-type": "application/json" },
|
|
127
|
+
headers: { "content-type": "application/json", ...csrfToken2 ? { "x-nordrelay-csrf": csrfToken2 } : {} },
|
|
115
128
|
body: proxyBody
|
|
116
129
|
});
|
|
117
130
|
if (res2.status === 401) {
|
|
@@ -127,8 +140,13 @@
|
|
|
127
140
|
return data2;
|
|
128
141
|
}
|
|
129
142
|
const body = normalizeBody(options.body);
|
|
143
|
+
const csrfToken = (
|
|
144
|
+
/** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
|
|
145
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
|
|
146
|
+
);
|
|
130
147
|
const headers = {
|
|
131
148
|
...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
|
|
149
|
+
...method !== "GET" && csrfToken ? { "x-nordrelay-csrf": csrfToken } : {},
|
|
132
150
|
...options.headers || {}
|
|
133
151
|
};
|
|
134
152
|
const res = await fetch(url.pathname + url.search, { method, headers, body });
|
|
@@ -148,7 +166,7 @@
|
|
|
148
166
|
const peerId = selectedPeerTarget();
|
|
149
167
|
if (!peerId || peerId === "local") return false;
|
|
150
168
|
if (!path.startsWith("/api/")) return false;
|
|
151
|
-
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
|
|
169
|
+
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || path === "/api/peers/discover" || path === "/api/peers/discovery-jobs" || path === "/api/peers/global-sessions" || path === "/api/peers/identity/backup" || path === "/api/peers/identity/restore" || path === "/api/settings/wizard/test" || /^\/api\/peers\/discovery-jobs\//.test(path) || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || /^\/api\/peers\/[^/]+\/repin$/.test(path) || isLocalAdminApi(path));
|
|
152
170
|
}
|
|
153
171
|
function isLocalAdminApi(path) {
|
|
154
172
|
return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || path === "/api/slack-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path) || /^\/api\/slack-channels\//.test(path);
|
|
@@ -230,20 +248,37 @@
|
|
|
230
248
|
throw new Error("Unsupported WebUI API method: " + method + " " + path);
|
|
231
249
|
}
|
|
232
250
|
}
|
|
233
|
-
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", 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, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
251
|
+
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, csrfToken: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, stickyToastActive: false, stickyToastText: "", cliStatusActive: false, webMirror: null, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, toolsVisible: false, agentUpdateJobs: [], sessionsRequestId: 0, chatHistoryRequestId: 0, chatRenderVersion: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, peerDiscoveryJobs: [], selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
234
252
|
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
|
|
235
253
|
function toast(msg, options = {}) {
|
|
236
254
|
const el = document.getElementById("toast");
|
|
237
|
-
|
|
238
|
-
el.style.display = "block";
|
|
255
|
+
const text = String(msg ?? "");
|
|
239
256
|
if (state.toastTimer) clearTimeout(state.toastTimer);
|
|
240
257
|
state.toastTimer = null;
|
|
241
|
-
if (
|
|
242
|
-
state.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
258
|
+
if (options.sticky) {
|
|
259
|
+
state.stickyToastActive = true;
|
|
260
|
+
state.stickyToastText = text;
|
|
261
|
+
if (el.textContent !== text) el.textContent = text;
|
|
262
|
+
if (el.style.display !== "block") el.style.display = "block";
|
|
263
|
+
return;
|
|
246
264
|
}
|
|
265
|
+
el.textContent = text;
|
|
266
|
+
el.style.display = "block";
|
|
267
|
+
state.toastTimer = setTimeout(() => {
|
|
268
|
+
state.toastTimer = null;
|
|
269
|
+
if (state.stickyToastActive) {
|
|
270
|
+
el.textContent = state.stickyToastText;
|
|
271
|
+
el.style.display = "block";
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
el.style.display = "none";
|
|
275
|
+
}, options.duration || 3500);
|
|
276
|
+
}
|
|
277
|
+
function clearStickyToast() {
|
|
278
|
+
state.stickyToastActive = false;
|
|
279
|
+
state.stickyToastText = "";
|
|
280
|
+
if (state.toastTimer) clearTimeout(state.toastTimer);
|
|
281
|
+
state.toastTimer = null;
|
|
247
282
|
}
|
|
248
283
|
function esc(s) {
|
|
249
284
|
return String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
@@ -325,6 +360,7 @@
|
|
|
325
360
|
["#newSessionBtn,#attachBtn,#createSessionBtn", "sessions.write"],
|
|
326
361
|
["#retryBtn", "prompt.send"],
|
|
327
362
|
["#syncBtn,#handbackBtn", "sessions.write"],
|
|
363
|
+
["#mirrorModeSelect", "settings.write"],
|
|
328
364
|
["#abortBtn", "prompt.abort"],
|
|
329
365
|
["#clearChatBtn", "sessions.write"],
|
|
330
366
|
["#saveSettingsBtn", "settings.write"],
|
|
@@ -334,7 +370,8 @@
|
|
|
334
370
|
["#clearLogsBtn", "logs.clear"],
|
|
335
371
|
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
|
|
336
372
|
["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
|
|
337
|
-
["#checkPeerReachabilityBtn,[data-peer-probe]", "peers.connect"],
|
|
373
|
+
["#checkPeerReachabilityBtn,#discoverPeersBtn,#cancelPeerDiscoveryBtn,[data-peer-probe]", "peers.connect"],
|
|
374
|
+
["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin]", "peers.write"],
|
|
338
375
|
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
339
376
|
["[data-switch]", "sessions.write"],
|
|
340
377
|
["[data-queue],[data-q]", "queue.write"],
|
|
@@ -360,10 +397,10 @@
|
|
|
360
397
|
return Math.floor(min / 60) + "h ago";
|
|
361
398
|
}
|
|
362
399
|
function isCliRunningStatus(msg) {
|
|
363
|
-
return / CLI running
|
|
400
|
+
return / CLI running\b/.test(String(msg || ""));
|
|
364
401
|
}
|
|
365
402
|
function isCliDoneStatus(msg) {
|
|
366
|
-
return / CLI task
|
|
403
|
+
return / CLI task (?:finished|completed|failed|aborted)\b/i.test(String(msg || ""));
|
|
367
404
|
}
|
|
368
405
|
function applyTheme(theme) {
|
|
369
406
|
document.documentElement.dataset.theme = theme;
|
|
@@ -373,6 +410,21 @@
|
|
|
373
410
|
function toggleTheme() {
|
|
374
411
|
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
|
|
375
412
|
}
|
|
413
|
+
function setToolsVisible(visible) {
|
|
414
|
+
state.toolsVisible = Boolean(visible);
|
|
415
|
+
const layout = document.getElementById("chatLayout");
|
|
416
|
+
const panel = document.getElementById("toolPanel");
|
|
417
|
+
const button = document.getElementById("toggleToolsBtn");
|
|
418
|
+
layout?.classList.toggle("tools-hidden", !state.toolsVisible);
|
|
419
|
+
if (panel) panel.hidden = !state.toolsVisible;
|
|
420
|
+
if (button) {
|
|
421
|
+
button.textContent = state.toolsVisible ? "Hide Tools" : "Show Tools";
|
|
422
|
+
button.setAttribute("aria-expanded", state.toolsVisible ? "true" : "false");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function toggleTools() {
|
|
426
|
+
setToolsVisible(!state.toolsVisible);
|
|
427
|
+
}
|
|
376
428
|
function page(name) {
|
|
377
429
|
state.currentPage = name;
|
|
378
430
|
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
|
|
@@ -385,8 +437,8 @@
|
|
|
385
437
|
const name = state.currentPage;
|
|
386
438
|
if (name === "overview") await loadActiveSessions();
|
|
387
439
|
if (name === "chat") {
|
|
388
|
-
await loadChatHistory();
|
|
389
|
-
scrollChatToBottom();
|
|
440
|
+
const [historyRendered] = await Promise.all([loadChatHistory({ forceScroll: true }), loadMirrorPreference()]);
|
|
441
|
+
if (historyRendered) scrollChatToBottom({ force: true });
|
|
390
442
|
}
|
|
391
443
|
if (name === "sessions") await loadSessions(true, options.agentId);
|
|
392
444
|
if (name === "settings") await loadSettings();
|
|
@@ -405,11 +457,36 @@
|
|
|
405
457
|
document.getElementById("menuBtn").onclick = () => document.getElementById("sidebar").classList.toggle("open");
|
|
406
458
|
document.getElementById("refreshBtn").onclick = () => loadBootstrap();
|
|
407
459
|
document.getElementById("themeBtn").onclick = toggleTheme;
|
|
460
|
+
document.getElementById("toggleToolsBtn").onclick = toggleTools;
|
|
408
461
|
document.getElementById("logoutBtn").onclick = () => safe(async () => {
|
|
409
462
|
await api("/api/dashboard/logout", { method: "POST" });
|
|
410
463
|
location.href = "/";
|
|
411
464
|
});
|
|
412
465
|
applyTheme(localStorage.getItem("nordrelayTheme") || "light");
|
|
466
|
+
setToolsVisible(false);
|
|
467
|
+
function uiBadge(text, status = "enabled") {
|
|
468
|
+
return '<span class="adapter-status ' + esc(status) + '">' + esc(text) + "</span>";
|
|
469
|
+
}
|
|
470
|
+
function uiRows(rows = []) {
|
|
471
|
+
return rows.filter(Boolean).map((row) => Array.isArray(row) ? "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>" : "<small>" + esc(row) + "</small>").join("");
|
|
472
|
+
}
|
|
473
|
+
function uiItem(title, options = {}) {
|
|
474
|
+
const badge = options.badge ? uiBadge(options.badge.text, options.badge.status) : "";
|
|
475
|
+
const rows = uiRows(options.rows || []);
|
|
476
|
+
const body = options.body || "";
|
|
477
|
+
const actions = options.actions ? '<div class="row">' + options.actions + "</div>" : "";
|
|
478
|
+
const titleAttr = options.title ? ' title="' + attr(options.title) + '"' : "";
|
|
479
|
+
return '<div class="item ' + (options.className ? attr(options.className) : "") + '"><strong' + titleAttr + ">" + esc(title) + " " + badge + "</strong>" + rows + body + actions + "</div>";
|
|
480
|
+
}
|
|
481
|
+
function uiEmpty(text) {
|
|
482
|
+
return '<div class="item">' + esc(text) + "</div>";
|
|
483
|
+
}
|
|
484
|
+
function uiCopyButton(value, label = "Copied", className = "copy-id") {
|
|
485
|
+
return value ? '<button type="button" class="' + attr(className) + '" data-copy-value="' + attr(value) + '" data-copy-label="' + attr(label) + '">' + esc(value) + "</button>" : "-";
|
|
486
|
+
}
|
|
487
|
+
function bindUiCopyButtons(root = document) {
|
|
488
|
+
root.querySelectorAll?.("[data-copy-value]").forEach((b) => b.onclick = () => copyText(b.dataset.copyValue || "", b.dataset.copyLabel || "Copied"));
|
|
489
|
+
}
|
|
413
490
|
function createPaginator(containerId, onChange, pageSize = 50) {
|
|
414
491
|
const container = document.getElementById(containerId);
|
|
415
492
|
return {
|
|
@@ -443,6 +520,7 @@
|
|
|
443
520
|
async function loadBootstrap() {
|
|
444
521
|
const local = await api("/api/bootstrap", { local: true });
|
|
445
522
|
state.auth = local.auth || null;
|
|
523
|
+
state.csrfToken = local.auth?.csrfToken || state.csrfToken || null;
|
|
446
524
|
state.permissions = local.auth?.permissions || [];
|
|
447
525
|
await loadPeerSelector();
|
|
448
526
|
const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
|
|
@@ -562,14 +640,14 @@
|
|
|
562
640
|
}
|
|
563
641
|
function activeSessionCard(s) {
|
|
564
642
|
const thread = s.threadId || "not started";
|
|
565
|
-
const
|
|
643
|
+
const prompt2 = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
|
|
566
644
|
const tool2 = s.currentTool || s.lastTool || "-";
|
|
567
645
|
const queue = s.queueLength ? " \xB7 " + s.queueLength + " queued" + (s.queuePaused ? " paused" : "") : "";
|
|
568
646
|
const sourceLabel = activeSourceLabel(s.source);
|
|
569
647
|
const mirrors = (s.mirrorChannels || []).map((m) => activeSourceLabel(m.source) + " " + m.mode + (m.queueLength ? " \xB7 " + m.queueLength + " queued" + (m.queuePaused ? " paused" : "") : "")).join(", ");
|
|
570
648
|
const meta = ["Source " + sourceLabel, s.workspace, fmtDuration(s.durationMs), tool2 && tool2 !== "-" ? "tool " + tool2 : ""].filter(Boolean).join(" | ");
|
|
571
649
|
const mirrorLine = mirrors ? "<small>Mirroring: " + esc(mirrors) + "</small>" : s.source === "cli" ? "<small>Mirroring: none</small>" : "";
|
|
572
|
-
return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine +
|
|
650
|
+
return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt2 + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
|
|
573
651
|
}
|
|
574
652
|
function activeSourceLabel(source) {
|
|
575
653
|
if (source === "cli") return "CLI";
|
|
@@ -672,9 +750,19 @@
|
|
|
672
750
|
if (status === "running") return "planned";
|
|
673
751
|
return "disabled";
|
|
674
752
|
}
|
|
675
|
-
|
|
753
|
+
const CHAT_CODE_BLOCK_PREFIX = "\uE010C";
|
|
754
|
+
const CHAT_CODE_BLOCK_SUFFIX = "\uE010";
|
|
755
|
+
const CHAT_INLINE_CODE_PREFIX = "\uE011I";
|
|
756
|
+
const CHAT_INLINE_CODE_SUFFIX = "\uE011";
|
|
757
|
+
function isChatNearBottom() {
|
|
758
|
+
const box = document.getElementById("messages");
|
|
759
|
+
if (!box) return true;
|
|
760
|
+
return box.scrollHeight - box.scrollTop - box.clientHeight < 80;
|
|
761
|
+
}
|
|
762
|
+
function scrollChatToBottom(options = {}) {
|
|
676
763
|
const box = document.getElementById("messages");
|
|
677
764
|
if (!box) return;
|
|
765
|
+
if (!options.force && !isChatNearBottom()) return;
|
|
678
766
|
requestAnimationFrame(() => {
|
|
679
767
|
box.scrollTop = box.scrollHeight;
|
|
680
768
|
requestAnimationFrame(() => {
|
|
@@ -682,39 +770,251 @@
|
|
|
682
770
|
});
|
|
683
771
|
});
|
|
684
772
|
}
|
|
685
|
-
function
|
|
773
|
+
function markChatRendered() {
|
|
774
|
+
state.chatRenderVersion = (state.chatRenderVersion || 0) + 1;
|
|
775
|
+
}
|
|
776
|
+
function appendMessage(cls, text, options = {}) {
|
|
686
777
|
const box = document.getElementById("messages");
|
|
778
|
+
const stick = options.forceScroll || isChatNearBottom();
|
|
779
|
+
const previousTop = box?.scrollTop ?? 0;
|
|
687
780
|
const div = document.createElement("div");
|
|
688
781
|
div.className = "message " + cls;
|
|
689
|
-
|
|
782
|
+
const body = document.createElement("div");
|
|
783
|
+
body.className = "message-body";
|
|
784
|
+
div.appendChild(body);
|
|
785
|
+
setMessageText(div, text);
|
|
690
786
|
box.appendChild(div);
|
|
691
|
-
scrollChatToBottom();
|
|
787
|
+
if (stick) scrollChatToBottom({ force: true });
|
|
788
|
+
else box.scrollTop = previousTop;
|
|
789
|
+
return div;
|
|
790
|
+
}
|
|
791
|
+
function messageBody(div) {
|
|
792
|
+
let body = div.querySelector?.(".message-body");
|
|
793
|
+
if (!body) {
|
|
794
|
+
body = document.createElement("div");
|
|
795
|
+
body.className = "message-body";
|
|
796
|
+
div.textContent = "";
|
|
797
|
+
div.appendChild(body);
|
|
798
|
+
}
|
|
799
|
+
return body;
|
|
800
|
+
}
|
|
801
|
+
function setMessageText(div, text) {
|
|
802
|
+
div.__rawText = String(text ?? "");
|
|
803
|
+
const body = messageBody(div);
|
|
804
|
+
body.innerHTML = renderChatMarkdown(div.__rawText);
|
|
805
|
+
bindChatCopyButtons(body);
|
|
806
|
+
markChatRendered();
|
|
692
807
|
return div;
|
|
693
808
|
}
|
|
694
809
|
function appendQueuedMessage(id) {
|
|
695
810
|
const div = appendMessage("system", "Queued prompt " + id);
|
|
811
|
+
const body = messageBody(div);
|
|
696
812
|
const btn = document.createElement("button");
|
|
697
813
|
btn.textContent = "Cancel queued message";
|
|
698
814
|
btn.className = "danger";
|
|
699
815
|
btn.onclick = () => safe(async () => {
|
|
700
816
|
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: "cancel", id }) });
|
|
701
817
|
renderQueue(r.queue, r.paused);
|
|
702
|
-
div
|
|
818
|
+
setMessageText(div, "Cancelled queued prompt " + id);
|
|
703
819
|
});
|
|
704
|
-
|
|
705
|
-
|
|
820
|
+
body.appendChild(document.createElement("br"));
|
|
821
|
+
body.appendChild(btn);
|
|
706
822
|
}
|
|
707
|
-
function renderChatMessages(messages) {
|
|
823
|
+
function renderChatMessages(messages, options = {}) {
|
|
708
824
|
state.chatMessages = messages || [];
|
|
709
825
|
const box = document.getElementById("messages");
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
826
|
+
const stick = options.forceScroll || isChatNearBottom();
|
|
827
|
+
const previousTop = box?.scrollTop ?? 0;
|
|
828
|
+
box.innerHTML = (messages || []).map((m) => '<div class="message ' + esc(m.role) + '"><small>' + esc((m.source || "web") + " / " + fmtDate(m.timestamp)) + '</small><br><div class="message-body">' + renderChatMarkdown(m.text) + "</div></div>").join("");
|
|
829
|
+
bindChatCopyButtons(box);
|
|
830
|
+
markChatRendered();
|
|
831
|
+
if (stick) scrollChatToBottom({ force: true });
|
|
832
|
+
else box.scrollTop = previousTop;
|
|
833
|
+
}
|
|
834
|
+
async function loadChatHistory(options = {}) {
|
|
835
|
+
const requestId = (state.chatHistoryRequestId || 0) + 1;
|
|
836
|
+
state.chatHistoryRequestId = requestId;
|
|
837
|
+
const renderVersion = state.chatRenderVersion || 0;
|
|
714
838
|
const data = await api("/api/chat/history");
|
|
715
|
-
|
|
839
|
+
if (requestId !== state.chatHistoryRequestId) return false;
|
|
840
|
+
if (options.skipIfRendered !== false && (state.chatRenderVersion || 0) !== renderVersion) return false;
|
|
841
|
+
renderChatMessages(data.messages || [], options);
|
|
842
|
+
return true;
|
|
716
843
|
}
|
|
717
844
|
let currentAgentMessage = null;
|
|
845
|
+
function renderChatMarkdown(text) {
|
|
846
|
+
let output = esc(String(text ?? ""));
|
|
847
|
+
const codeBlocks = [];
|
|
848
|
+
const inlineCode = [];
|
|
849
|
+
output = extractChatCodeBlocks(output, codeBlocks);
|
|
850
|
+
output = extractChatInlineCode(output, inlineCode);
|
|
851
|
+
output = formatChatBold(output);
|
|
852
|
+
output = formatChatItalic(output);
|
|
853
|
+
output = formatChatLinks(output);
|
|
854
|
+
output = formatChatBlockquotes(output);
|
|
855
|
+
output = formatChatLists(output);
|
|
856
|
+
output = formatChatHeadings(output);
|
|
857
|
+
output = restoreChatMarkdown(output, CHAT_INLINE_CODE_PREFIX, CHAT_INLINE_CODE_SUFFIX, inlineCode);
|
|
858
|
+
output = restoreChatMarkdown(output, CHAT_CODE_BLOCK_PREFIX, CHAT_CODE_BLOCK_SUFFIX, codeBlocks);
|
|
859
|
+
return output;
|
|
860
|
+
}
|
|
861
|
+
function extractChatCodeBlocks(text, blocks) {
|
|
862
|
+
return text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_match, rawLanguage, rawCode) => {
|
|
863
|
+
const language = String(rawLanguage || "").trim().replace(/[^a-zA-Z0-9_+-]/g, "");
|
|
864
|
+
const className = language ? ' class="language-' + attr(language) + '"' : "";
|
|
865
|
+
const label = language ? ' data-code-language="' + attr(language) + '"' : "";
|
|
866
|
+
const block = '<pre class="chat-code-block" tabindex="0" title="Copy code" data-chat-copy="code-block"' + label + "><code" + className + ">" + rawCode + "</code></pre>";
|
|
867
|
+
const index = blocks.push(block) - 1;
|
|
868
|
+
return CHAT_CODE_BLOCK_PREFIX + index + CHAT_CODE_BLOCK_SUFFIX;
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
function extractChatInlineCode(text, inline) {
|
|
872
|
+
let result = "";
|
|
873
|
+
let index = 0;
|
|
874
|
+
while (index < text.length) {
|
|
875
|
+
if (text[index] !== "`") {
|
|
876
|
+
result += text[index];
|
|
877
|
+
index += 1;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
let tickCount = 1;
|
|
881
|
+
while (text[index + tickCount] === "`") tickCount += 1;
|
|
882
|
+
const fence = "`".repeat(tickCount);
|
|
883
|
+
const start = index + tickCount;
|
|
884
|
+
const end = text.indexOf(fence, start);
|
|
885
|
+
if (end === -1) {
|
|
886
|
+
result += fence;
|
|
887
|
+
index += tickCount;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const content = text.slice(start, end);
|
|
891
|
+
if (content.includes("\n")) {
|
|
892
|
+
result += fence;
|
|
893
|
+
index += tickCount;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const button = '<button type="button" class="chat-inline-code copy-id" title="Copy code" data-chat-copy="inline-code">' + content + "</button>";
|
|
897
|
+
result += CHAT_INLINE_CODE_PREFIX + (inline.push(button) - 1) + CHAT_INLINE_CODE_SUFFIX;
|
|
898
|
+
index = end + tickCount;
|
|
899
|
+
}
|
|
900
|
+
return result;
|
|
901
|
+
}
|
|
902
|
+
function formatChatBold(text) {
|
|
903
|
+
return text.replace(/(?<!\*)\*\*(?!\s)([^\n]*?\S)\*\*(?!\*)/g, "<strong>$1</strong>");
|
|
904
|
+
}
|
|
905
|
+
function formatChatItalic(text) {
|
|
906
|
+
return text.replace(/(?<![\w_])_(?!\s)([^_\n]*?\S)_(?![\w_])/g, "<em>$1</em>").replace(/(?<![\w*])\*(?!\s)([^*\n]*?\S)\*(?![\w*])/g, "<em>$1</em>");
|
|
907
|
+
}
|
|
908
|
+
function formatChatLinks(text) {
|
|
909
|
+
return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => {
|
|
910
|
+
const safeUrl = sanitizeChatUrl(String(url).replace(/&/g, "&"));
|
|
911
|
+
return '<a class="chat-link" href="' + attr(safeUrl) + '" target="_blank" rel="noreferrer noopener">' + label + "</a>";
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
function sanitizeChatUrl(url) {
|
|
915
|
+
const trimmed = String(url || "").trim().replace(/"/g, "%22");
|
|
916
|
+
return /^(https?|mailto):/i.test(trimmed) ? trimmed : "#";
|
|
917
|
+
}
|
|
918
|
+
function formatChatBlockquotes(text) {
|
|
919
|
+
const lines = text.split("\n");
|
|
920
|
+
const out = [];
|
|
921
|
+
let quote = [];
|
|
922
|
+
const flush = () => {
|
|
923
|
+
if (quote.length) {
|
|
924
|
+
out.push('<blockquote class="chat-blockquote">' + quote.join("\n") + "</blockquote>");
|
|
925
|
+
quote = [];
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
for (const line of lines) {
|
|
929
|
+
const match = line.match(/^> ?(.*)$/);
|
|
930
|
+
if (match) {
|
|
931
|
+
quote.push(match[1]);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
flush();
|
|
935
|
+
out.push(line);
|
|
936
|
+
}
|
|
937
|
+
flush();
|
|
938
|
+
return out.join("\n");
|
|
939
|
+
}
|
|
940
|
+
function formatChatLists(text) {
|
|
941
|
+
const lines = text.split("\n");
|
|
942
|
+
const out = [];
|
|
943
|
+
let list = null;
|
|
944
|
+
const flush = () => {
|
|
945
|
+
if (list) {
|
|
946
|
+
out.push("</" + list + ">");
|
|
947
|
+
list = null;
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
for (const line of lines) {
|
|
951
|
+
const task = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.+)$/);
|
|
952
|
+
const bullet = line.match(/^\s*[-*]\s+(.+)$/);
|
|
953
|
+
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
954
|
+
if (task) {
|
|
955
|
+
if (list !== "ul") {
|
|
956
|
+
flush();
|
|
957
|
+
out.push('<ul class="chat-list chat-task-list">');
|
|
958
|
+
list = "ul";
|
|
959
|
+
}
|
|
960
|
+
const checked = task[1].toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
961
|
+
out.push('<li><span class="chat-task-mark">' + checked + "</span> " + task[2] + "</li>");
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (bullet) {
|
|
965
|
+
if (list !== "ul") {
|
|
966
|
+
flush();
|
|
967
|
+
out.push('<ul class="chat-list">');
|
|
968
|
+
list = "ul";
|
|
969
|
+
}
|
|
970
|
+
out.push("<li>" + bullet[1] + "</li>");
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (ordered) {
|
|
974
|
+
if (list !== "ol") {
|
|
975
|
+
flush();
|
|
976
|
+
out.push('<ol class="chat-list">');
|
|
977
|
+
list = "ol";
|
|
978
|
+
}
|
|
979
|
+
out.push("<li>" + ordered[1] + "</li>");
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
flush();
|
|
983
|
+
out.push(line);
|
|
984
|
+
}
|
|
985
|
+
flush();
|
|
986
|
+
return out.join("\n");
|
|
987
|
+
}
|
|
988
|
+
function formatChatHeadings(text) {
|
|
989
|
+
return text.split("\n").map((line) => {
|
|
990
|
+
const match = line.match(/^(#{1,4})\s+(.+)$/);
|
|
991
|
+
return match ? '<strong class="chat-heading chat-heading-' + match[1].length + '">' + match[2] + "</strong>" : line;
|
|
992
|
+
}).join("\n");
|
|
993
|
+
}
|
|
994
|
+
function restoreChatMarkdown(text, prefix, suffix, values) {
|
|
995
|
+
const pattern = new RegExp(escapeChatRegExp(prefix) + "(\\d+)" + escapeChatRegExp(suffix), "g");
|
|
996
|
+
return text.replace(pattern, (_match, rawIndex) => values[Number.parseInt(rawIndex, 10)] ?? "");
|
|
997
|
+
}
|
|
998
|
+
function escapeChatRegExp(text) {
|
|
999
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1000
|
+
}
|
|
1001
|
+
function bindChatCopyButtons(root) {
|
|
1002
|
+
root.querySelectorAll?.("[data-chat-copy]").forEach((el) => {
|
|
1003
|
+
el.onclick = (event) => {
|
|
1004
|
+
event.preventDefault();
|
|
1005
|
+
event.stopPropagation();
|
|
1006
|
+
const code = el.dataset.chatCopy === "code-block" ? el.querySelector("code")?.textContent : el.textContent;
|
|
1007
|
+
copyText(code || "", "Copied code");
|
|
1008
|
+
};
|
|
1009
|
+
el.onkeydown = (event) => {
|
|
1010
|
+
if (el.tagName === "BUTTON") return;
|
|
1011
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1012
|
+
event.preventDefault();
|
|
1013
|
+
el.click();
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
718
1018
|
function connectEvents() {
|
|
719
1019
|
if (state.events) state.events.close();
|
|
720
1020
|
const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
|
|
@@ -765,9 +1065,10 @@
|
|
|
765
1065
|
});
|
|
766
1066
|
events.addEventListener("text_delta", (e) => {
|
|
767
1067
|
const d = JSON.parse(e.data);
|
|
1068
|
+
const stick = isChatNearBottom();
|
|
768
1069
|
if (!currentAgentMessage) currentAgentMessage = appendMessage("agent", "");
|
|
769
|
-
currentAgentMessage.
|
|
770
|
-
scrollChatToBottom();
|
|
1070
|
+
setMessageText(currentAgentMessage, (currentAgentMessage.__rawText || "") + d.delta);
|
|
1071
|
+
if (stick) scrollChatToBottom({ force: true });
|
|
771
1072
|
if (state.currentPage === "tasks") loadTasks();
|
|
772
1073
|
});
|
|
773
1074
|
events.addEventListener("tool_start", (e) => {
|
|
@@ -785,7 +1086,7 @@
|
|
|
785
1086
|
});
|
|
786
1087
|
events.addEventListener("todo_update", (e) => {
|
|
787
1088
|
const d = JSON.parse(e.data);
|
|
788
|
-
tool("tool", "Plan
|
|
1089
|
+
tool("tool", "Plan:\n" + d.items.map((i) => (i.completed ? "[x] " : "[ ] ") + i.text).join("\n"));
|
|
789
1090
|
});
|
|
790
1091
|
events.addEventListener("turn_error", (e) => {
|
|
791
1092
|
const d = JSON.parse(e.data);
|
|
@@ -806,7 +1107,10 @@
|
|
|
806
1107
|
toast(msg, { sticky: true });
|
|
807
1108
|
return;
|
|
808
1109
|
}
|
|
809
|
-
if (isCliDoneStatus(msg))
|
|
1110
|
+
if (isCliDoneStatus(msg)) {
|
|
1111
|
+
state.cliStatusActive = false;
|
|
1112
|
+
clearStickyToast();
|
|
1113
|
+
}
|
|
810
1114
|
toast(msg);
|
|
811
1115
|
});
|
|
812
1116
|
events.onerror = () => {
|
|
@@ -971,6 +1275,27 @@
|
|
|
971
1275
|
});
|
|
972
1276
|
document.getElementById("promptForm").onsubmit = (e) => safe(async () => {
|
|
973
1277
|
e.preventDefault();
|
|
1278
|
+
const input = document.getElementById("promptInput");
|
|
1279
|
+
const text = input.value.trim();
|
|
1280
|
+
if (/^\/mirror\b/i.test(text)) {
|
|
1281
|
+
if (selectedFiles.length) {
|
|
1282
|
+
toast("/mirror cannot be sent with attachments");
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const argument = text.replace(/^\/mirror\b/i, "").trim();
|
|
1286
|
+
if (argument && !can("settings.write")) {
|
|
1287
|
+
toast("Permission required: settings.write");
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
if (!argument && !can("sessions.read")) {
|
|
1291
|
+
toast("Permission required: sessions.read");
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
input.value = "";
|
|
1295
|
+
const data = argument ? await setMirrorPreference(argument) : await loadMirrorPreference();
|
|
1296
|
+
appendMessage("system", data?.response?.plain || "CLI mirroring: " + (data?.mode || "-"));
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
974
1299
|
if (!can("prompt.send")) {
|
|
975
1300
|
toast("Permission required: prompt.send");
|
|
976
1301
|
return;
|
|
@@ -979,8 +1304,6 @@
|
|
|
979
1304
|
toast("Permission required: files.write");
|
|
980
1305
|
return;
|
|
981
1306
|
}
|
|
982
|
-
const input = document.getElementById("promptInput");
|
|
983
|
-
const text = input.value.trim();
|
|
984
1307
|
if (!text && selectedFiles.length === 0) return;
|
|
985
1308
|
const files = selectedFiles;
|
|
986
1309
|
input.value = "";
|
|
@@ -989,7 +1312,7 @@
|
|
|
989
1312
|
renderSelectedFiles();
|
|
990
1313
|
const payloadFiles = files.length ? await Promise.all(files.map(filePayload)) : [];
|
|
991
1314
|
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 }) });
|
|
992
|
-
if (r.transcribeOnly) appendMessage("system", "Transcribed audio
|
|
1315
|
+
if (r.transcribeOnly) appendMessage("system", "Transcribed audio:\n" + (r.transcript || "(empty)"));
|
|
993
1316
|
else if (r.queued) appendQueuedMessage(r.queueId);
|
|
994
1317
|
}, e);
|
|
995
1318
|
document.getElementById("newSessionBtn").onclick = () => {
|
|
@@ -1024,6 +1347,14 @@
|
|
|
1024
1347
|
loadBootstrap();
|
|
1025
1348
|
});
|
|
1026
1349
|
document.getElementById("notifyBtn").onclick = () => enableNotifications();
|
|
1350
|
+
document.getElementById("mirrorModeSelect").onchange = () => safe(async () => {
|
|
1351
|
+
if (!can("settings.write")) {
|
|
1352
|
+
toast("Permission required: settings.write");
|
|
1353
|
+
renderMirrorPreference(state.webMirror);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
await setMirrorPreference(document.getElementById("mirrorModeSelect").value);
|
|
1357
|
+
});
|
|
1027
1358
|
document.getElementById("clearChatBtn").onclick = () => safe(async () => {
|
|
1028
1359
|
if (!can("sessions.write")) {
|
|
1029
1360
|
toast("Permission required: sessions.write");
|
|
@@ -1049,7 +1380,7 @@
|
|
|
1049
1380
|
return;
|
|
1050
1381
|
}
|
|
1051
1382
|
const r = await api("/api/handback", { method: "POST" });
|
|
1052
|
-
appendMessage("system", "Handback command
|
|
1383
|
+
appendMessage("system", "Handback command:\n" + (r.command || "No command available"));
|
|
1053
1384
|
});
|
|
1054
1385
|
document.getElementById("recordBtn").onclick = () => safe(async () => {
|
|
1055
1386
|
if (!can("files.write")) {
|
|
@@ -1077,6 +1408,24 @@
|
|
|
1077
1408
|
state.mediaRecorder.start();
|
|
1078
1409
|
btn.textContent = "Stop recording";
|
|
1079
1410
|
});
|
|
1411
|
+
function renderMirrorPreference(data) {
|
|
1412
|
+
if (!data) return;
|
|
1413
|
+
state.webMirror = data;
|
|
1414
|
+
const select = document.getElementById("mirrorModeSelect");
|
|
1415
|
+
if (select && data.mode) select.value = data.mode;
|
|
1416
|
+
}
|
|
1417
|
+
async function loadMirrorPreference() {
|
|
1418
|
+
if (!can("sessions.read")) return null;
|
|
1419
|
+
const data = await api("/api/chat/mirror");
|
|
1420
|
+
renderMirrorPreference(data);
|
|
1421
|
+
return data;
|
|
1422
|
+
}
|
|
1423
|
+
async function setMirrorPreference(argument) {
|
|
1424
|
+
const data = await api("/api/chat/mirror", { method: "POST", body: JSON.stringify({ argument }) });
|
|
1425
|
+
renderMirrorPreference(data);
|
|
1426
|
+
toast("Mirror " + data.mode);
|
|
1427
|
+
return data;
|
|
1428
|
+
}
|
|
1080
1429
|
function renderNewSessionControls(c) {
|
|
1081
1430
|
const s = state.snapshot?.session || {};
|
|
1082
1431
|
const caps = c.capabilities || {};
|
|
@@ -1120,7 +1469,7 @@
|
|
|
1120
1469
|
document.getElementById("newSessionDialog").close();
|
|
1121
1470
|
toast("New session started");
|
|
1122
1471
|
await loadBootstrap();
|
|
1123
|
-
await loadChatHistory();
|
|
1472
|
+
await loadChatHistory({ forceScroll: true });
|
|
1124
1473
|
}, e);
|
|
1125
1474
|
document.getElementById("cancelSessionBtn").onclick = () => document.getElementById("newSessionDialog").close();
|
|
1126
1475
|
function val(id) {
|
|
@@ -1488,10 +1837,16 @@
|
|
|
1488
1837
|
["Buckets", (rate?.buckets || []).length]
|
|
1489
1838
|
].map(([k, v]) => [name + " " + k, v]);
|
|
1490
1839
|
}
|
|
1840
|
+
function webRouteRows(d) {
|
|
1841
|
+
return (d.web?.routes || []).slice(0, 8).map((route) => [route.method + " " + route.path, route.averageMs + "ms avg / " + route.maxMs + "ms max / " + route.count + " hit(s)"]);
|
|
1842
|
+
}
|
|
1843
|
+
function webSlowRows(d) {
|
|
1844
|
+
return (d.web?.slowest || []).slice(0, 8).map((sample) => [sample.method + " " + sample.path, sample.durationMs + "ms / " + sample.statusCode + " / " + fmtDate(sample.at)]);
|
|
1845
|
+
}
|
|
1491
1846
|
function renderMetrics(d) {
|
|
1492
1847
|
const adapters = d.adapters || {};
|
|
1493
1848
|
const adapterCards = Object.entries(adapters).map(([name, rate]) => card(name.charAt(0).toUpperCase() + name.slice(1) + " rate limits", rateRows("", rate).map(([k, v]) => [String(k).trim(), v]))).join("");
|
|
1494
|
-
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + adapterCards + "</div>";
|
|
1849
|
+
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + card("Web API latency", webRouteRows(d)) + card("Slow Web API calls", webSlowRows(d)) + adapterCards + "</div>";
|
|
1495
1850
|
}
|
|
1496
1851
|
document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
|
|
1497
1852
|
function activityQuery() {
|
|
@@ -2151,14 +2506,17 @@
|
|
|
2151
2506
|
return;
|
|
2152
2507
|
}
|
|
2153
2508
|
setLoading("peersList", "Loading peers...");
|
|
2154
|
-
const d = await api("/api/peers", { local: true });
|
|
2509
|
+
const [d, jobsData] = await Promise.all([api("/api/peers", { local: true }), can("peers.connect") ? api("/api/peers/discovery-jobs", { local: true }).catch(() => ({ jobs: [] })) : Promise.resolve({ jobs: [] })]);
|
|
2155
2510
|
state.peers = d;
|
|
2511
|
+
state.peerDiscoveryJobs = jobsData.jobs || [];
|
|
2156
2512
|
const inviteIds = new Set((d.invitations || []).map((i) => i.id));
|
|
2157
2513
|
Object.keys(state.peerInviteSecrets || {}).forEach((id) => {
|
|
2158
2514
|
if (!inviteIds.has(id)) delete state.peerInviteSecrets[id];
|
|
2159
2515
|
});
|
|
2160
2516
|
document.getElementById("peerStatus").innerHTML = peerStatusHtml(d);
|
|
2161
2517
|
document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
|
|
2518
|
+
document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobsHtml(state.peerDiscoveryJobs);
|
|
2519
|
+
bindUiCopyButtons(document.getElementById("peerDiscovery"));
|
|
2162
2520
|
document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
|
|
2163
2521
|
ensureGlobalPeerSessionsPanel();
|
|
2164
2522
|
bindPeerButtons();
|
|
@@ -2167,8 +2525,9 @@
|
|
|
2167
2525
|
}
|
|
2168
2526
|
function openPeerAddDialog() {
|
|
2169
2527
|
const publicUrl = state.peers?.enabled ? state.peers?.listenUrl || "" : "";
|
|
2170
|
-
adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
|
|
2528
|
+
adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Group<input id="dlgPeerAddGroup" placeholder="LAN, Servers, Workstations"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
|
|
2171
2529
|
const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
|
|
2530
|
+
if (val("dlgPeerAddGroup") && r.peer?.id) await api("/api/peers/" + encodeURIComponent(r.peer.id), { method: "PATCH", body: JSON.stringify({ group: val("dlgPeerAddGroup") }), local: true });
|
|
2172
2531
|
toast("Added peer " + (r.peer?.name || ""));
|
|
2173
2532
|
await loadPeers();
|
|
2174
2533
|
});
|
|
@@ -2188,6 +2547,10 @@
|
|
|
2188
2547
|
}
|
|
2189
2548
|
openPeerAddDialog();
|
|
2190
2549
|
};
|
|
2550
|
+
document.getElementById("discoverPeersBtn").onclick = () => safe(discoverPeers);
|
|
2551
|
+
document.getElementById("cancelPeerDiscoveryBtn").onclick = () => safe(cancelPeerDiscovery);
|
|
2552
|
+
document.getElementById("exportPeerIdentityBtn").onclick = () => safe(exportPeerIdentity);
|
|
2553
|
+
document.getElementById("restorePeerIdentityBtn").onclick = () => safe(restorePeerIdentity);
|
|
2191
2554
|
async function loadAdapterHealth() {
|
|
2192
2555
|
setLoading("adapterHealth", "Loading adapters...");
|
|
2193
2556
|
setLoading("adapterConformance", "Loading conformance...");
|
|
@@ -2350,6 +2713,21 @@
|
|
|
2350
2713
|
}));
|
|
2351
2714
|
}
|
|
2352
2715
|
document.getElementById("loadVersionBtn").onclick = () => loadVersion();
|
|
2716
|
+
document.addEventListener("click", (e) => {
|
|
2717
|
+
const b = e.target.closest?.("[data-peer-repin]");
|
|
2718
|
+
if (!b) return;
|
|
2719
|
+
safe(async () => {
|
|
2720
|
+
if (!can("peers.write")) {
|
|
2721
|
+
toast("Permission required: peers.write");
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
if (confirm("Re-pin TLS fingerprint for this peer?")) {
|
|
2725
|
+
const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerRepin) + "/repin", { method: "POST", local: true });
|
|
2726
|
+
toast("TLS fingerprint updated: " + (r.peer?.tlsFingerprint || "-"), { duration: 8e3 });
|
|
2727
|
+
loadPeers();
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
});
|
|
2353
2731
|
document.getElementById("updateBtn").onclick = () => safe(async () => {
|
|
2354
2732
|
if (!can("updates.run")) {
|
|
2355
2733
|
toast("Permission required: updates.run");
|
|
@@ -2429,6 +2807,92 @@
|
|
|
2429
2807
|
const command = r.manualCheckCommand || "nordrelay peer check " + (d.listenUrl || "");
|
|
2430
2808
|
return '<div class="item"><strong>Local peer identity <span class="adapter-status ' + (r.enabled && r.localListening ? "enabled" : r.enabled ? "planned" : "disabled") + '">' + esc(r.enabled ? r.localListening ? "ready" : "not listening" : "disabled") + "</span></strong>" + rows.map((row) => "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>").join("") + peerWarningsHtml(r.warnings || [], "Peer readiness") + '<div class="peer-invite-details"><small>Manual reachability check</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(command) + '" data-peer-invite-copy-label="Peer check command copied">' + esc(command) + '</button><small>Run this on another machine to verify LAN, port-forward, or firewall reachability.</small></div><div class="row"><button id="checkPeerReachabilityBtn" class="secondary"' + disabledAttr("peers.connect") + '>Check local endpoint</button></div><div id="peerProbeResult">' + peerProbeResultHtml(state.peerProbeResult) + "</div></div>";
|
|
2431
2809
|
}
|
|
2810
|
+
async function discoverPeers() {
|
|
2811
|
+
if (!can("peers.connect")) {
|
|
2812
|
+
toast("Permission required: peers.connect");
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
const target = document.getElementById("peerDiscovery");
|
|
2816
|
+
target.innerHTML = loadingHtml("Starting LAN peer discovery...");
|
|
2817
|
+
const body = { targets: csvToList(val("peerDiscoveryTargets")), maxHosts: Number(val("peerDiscoveryMaxHosts") || 512), concurrency: Number(val("peerDiscoveryConcurrency") || 32) };
|
|
2818
|
+
const data = await api("/api/peers/discovery-jobs", { method: "POST", body: JSON.stringify(body), local: true });
|
|
2819
|
+
state.activePeerDiscoveryJobId = data.job?.id;
|
|
2820
|
+
await pollPeerDiscoveryJob(data.job?.id);
|
|
2821
|
+
}
|
|
2822
|
+
async function pollPeerDiscoveryJob(id) {
|
|
2823
|
+
if (!id) return;
|
|
2824
|
+
const target = document.getElementById("peerDiscovery");
|
|
2825
|
+
for (; ; ) {
|
|
2826
|
+
const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id), { local: true });
|
|
2827
|
+
const job = data.job;
|
|
2828
|
+
if (!job) {
|
|
2829
|
+
target.innerHTML = uiEmpty("Discovery job not found.");
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
target.innerHTML = peerDiscoveryJobHtml(job);
|
|
2833
|
+
bindUiCopyButtons(target);
|
|
2834
|
+
applyPermissions();
|
|
2835
|
+
if (!["queued", "running"].includes(job.status)) break;
|
|
2836
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
async function cancelPeerDiscovery() {
|
|
2840
|
+
const id = state.activePeerDiscoveryJobId;
|
|
2841
|
+
if (!id) {
|
|
2842
|
+
toast("No active discovery job");
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id) + "/cancel", { method: "POST", local: true });
|
|
2846
|
+
if (data.job) {
|
|
2847
|
+
document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobHtml(data.job);
|
|
2848
|
+
}
|
|
2849
|
+
toast("Discovery cancelled");
|
|
2850
|
+
}
|
|
2851
|
+
async function exportPeerIdentity() {
|
|
2852
|
+
if (!can("peers.write")) {
|
|
2853
|
+
toast("Permission required: peers.write");
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
const data = await api("/api/peers/identity/backup", { local: true });
|
|
2857
|
+
downloadJson("nordrelay-peer-identity-backup.json", data.backup);
|
|
2858
|
+
toast("Peer identity backup exported");
|
|
2859
|
+
}
|
|
2860
|
+
async function restorePeerIdentity() {
|
|
2861
|
+
if (!can("peers.write")) {
|
|
2862
|
+
toast("Permission required: peers.write");
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
const text = prompt("Paste peer identity backup JSON");
|
|
2866
|
+
if (!text) return;
|
|
2867
|
+
const backup = JSON.parse(text);
|
|
2868
|
+
await api("/api/peers/identity/restore", { method: "POST", body: JSON.stringify({ backup }), local: true });
|
|
2869
|
+
toast("Peer identity restored. Restart peer server to use it.");
|
|
2870
|
+
await loadPeers();
|
|
2871
|
+
}
|
|
2872
|
+
function downloadJson(name, value) {
|
|
2873
|
+
const blob = new Blob([JSON.stringify(value, null, 2) + "\n"], { type: "application/json" });
|
|
2874
|
+
const a = document.createElement("a");
|
|
2875
|
+
a.href = URL.createObjectURL(blob);
|
|
2876
|
+
a.download = name;
|
|
2877
|
+
a.click();
|
|
2878
|
+
URL.revokeObjectURL(a.href);
|
|
2879
|
+
}
|
|
2880
|
+
function peerDiscoveryJobsHtml(jobs) {
|
|
2881
|
+
const list = (jobs || []).slice(0, 5);
|
|
2882
|
+
return list.map(peerDiscoveryJobHtml).join("") || uiEmpty("No LAN discovery jobs yet.");
|
|
2883
|
+
}
|
|
2884
|
+
function peerDiscoveryJobHtml(job) {
|
|
2885
|
+
const progress = job.total ? Math.round(job.scanned / job.total * 100) : 0;
|
|
2886
|
+
return uiItem("Discovery job " + job.id, { badge: { text: job.status, status: job.status === "completed" ? "enabled" : job.status === "failed" ? "disabled" : "planned" }, rows: [["Progress", job.scanned + " / " + job.total + " (" + progress + "%)"], ["Targets", (job.options?.targets || []).join(", ") || "local LAN + mDNS"], ["Started", fmtDate(job.startedAt)], ["Completed", fmtDate(job.completedAt)]], body: peerDiscoveryHtml(job) + '<details class="peer-health-history"><summary>Discovery log (' + (job.log || []).length + ")</summary>" + (job.log || []).slice(-30).map((line) => "<small>" + esc(line) + "</small>").join("") + "</details>" });
|
|
2887
|
+
}
|
|
2888
|
+
function peerDiscoveryHtml(data) {
|
|
2889
|
+
const warnings = (data.warnings || []).length ? peerWarningsHtml(data.warnings, "Discovery warning") : "";
|
|
2890
|
+
const cards = (data.candidates || []).map((c) => {
|
|
2891
|
+
const command = "nordrelay peer add " + c.url + " --code <pairing-code>";
|
|
2892
|
+
return uiItem(c.name || c.host, { badge: { text: "found", status: "enabled" }, rows: [["URL", c.url], ["Node", c.nodeId], ["Fingerprint", c.fingerprint], ["TLS", c.tlsFingerprint || "-"], ["Latency", c.latencyMs !== void 0 ? c.latencyMs + "ms" : "-"]], body: '<div class="peer-invite-details"><small>Pairing command template</small>' + uiCopyButton(command, "Peer add command copied", "copy-id peer-invite-command") + "</div>" });
|
|
2893
|
+
}).join("");
|
|
2894
|
+
return warnings + (cards || uiEmpty("No LAN peers found. Scanned " + (data.scanned || 0) + " endpoint candidates."));
|
|
2895
|
+
}
|
|
2432
2896
|
function peerWarningsHtml(warnings, title) {
|
|
2433
2897
|
return (warnings || []).length ? '<div class="peer-warning full-span"><strong>' + esc(title || "Warning") + "</strong>" + warnings.map((w) => "<small>" + esc(w) + "</small>").join("") + "</div>" : "";
|
|
2434
2898
|
}
|
|
@@ -2446,18 +2910,20 @@
|
|
|
2446
2910
|
function peerInviteCard(i) {
|
|
2447
2911
|
const open = new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt;
|
|
2448
2912
|
const readiness = state.peers?.readiness;
|
|
2449
|
-
return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong
|
|
2913
|
+
return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong>" + (i.group ? "<small>" + esc("Group: " + i.group) + "</small>" : "") + "<small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
|
|
2450
2914
|
}
|
|
2451
2915
|
function peerCard(p) {
|
|
2452
2916
|
const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
|
|
2453
2917
|
const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
|
|
2454
2918
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2455
|
-
|
|
2919
|
+
const effective = "Effective access: " + (p.scopes || []).length + " scope(s), agents " + ((p.allowedAgents || []).join(", ") || "all") + ", workspaces " + ((p.allowedWorkspaceRoots || []).join(", ") || "all");
|
|
2920
|
+
const history = (p.healthHistory || []).slice(-5).reverse().map((h) => "<small>" + esc(fmtDate(h.checkedAt) + " | " + h.status + (h.latencyMs !== void 0 ? " | " + h.latencyMs + "ms" : "") + (h.error ? " | " + h.error : "")) + "</small>").join("");
|
|
2921
|
+
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong>" + (p.group ? "<small>" + esc("Group: " + p.group) + "</small>" : "") + "<small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(effective) + "</small><small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + (history ? '<details class="peer-health-history"><summary>Health history (' + (p.healthHistory || []).length + ")</summary>" + history + "</details>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-repin="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Re-pin TLS</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
|
|
2456
2922
|
}
|
|
2457
2923
|
function openPeerDialog(p) {
|
|
2458
2924
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2459
|
-
adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
|
|
2460
|
-
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
|
|
2925
|
+
adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>Group<input id="dlgPeerGroup" value="' + attr(p.group || "") + '" placeholder="LAN, Servers, Workstations"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
|
|
2926
|
+
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), group: val("dlgPeerGroup"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
|
|
2461
2927
|
toast("Peer updated");
|
|
2462
2928
|
await loadPeers();
|
|
2463
2929
|
});
|
|
@@ -2465,8 +2931,8 @@
|
|
|
2465
2931
|
function openPeerInviteDialog() {
|
|
2466
2932
|
const warnings = state.peers?.readiness?.warnings || [];
|
|
2467
2933
|
const warningHtml = peerWarningsHtml(warnings, "Pairing warning") + (warnings.length ? '<small class="full-span">The invite can still be created, but pairing may fail until the peer endpoint is reachable.</small>' : "");
|
|
2468
|
-
adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
|
|
2469
|
-
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
|
|
2934
|
+
adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Group<input id="dlgPeerInviteGroup" placeholder="LAN, Servers, Workstations"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
|
|
2935
|
+
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), group: val("dlgPeerInviteGroup") || void 0, expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
|
|
2470
2936
|
if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
|
|
2471
2937
|
toast("Peer invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
|
|
2472
2938
|
await loadPeers();
|