@nordbyte/nordrelay 0.2.1 → 0.3.1
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 +22 -0
- package/CHANGELOG.md +26 -0
- package/README.md +147 -19
- package/dist/access-control.js +6 -0
- package/dist/agent-adapter.js +60 -0
- package/dist/audit-log.js +54 -0
- package/dist/bot-preferences.js +13 -9
- package/dist/bot-ui.js +6 -0
- package/dist/bot.js +526 -26
- package/dist/channel-adapter.js +58 -0
- package/dist/codex-session.js +3 -1
- package/dist/config.js +47 -0
- package/dist/context-key.js +23 -0
- package/dist/index.js +47 -2
- package/dist/logger.js +24 -1
- package/dist/operations.js +340 -15
- package/dist/prompt-store.js +33 -11
- package/dist/relay-runtime.js +908 -0
- package/dist/session-locks.js +81 -0
- package/dist/session-registry.js +11 -7
- package/dist/settings-service.js +253 -0
- package/dist/state-backend.js +83 -0
- package/dist/web-dashboard.js +890 -0
- package/dist/web-state.js +131 -0
- package/docker-compose.yml +1 -1
- package/package.json +4 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +235 -13
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { URL } from "node:url";
|
|
6
|
+
import { enabledAgents } from "./agent-factory.js";
|
|
7
|
+
import { listAgentAdapterDescriptors } from "./agent-adapter.js";
|
|
8
|
+
import { isAgentId } from "./agent.js";
|
|
9
|
+
import { listChannelDescriptors } from "./channel-adapter.js";
|
|
10
|
+
import { loadConfig } from "./config.js";
|
|
11
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
12
|
+
import { escapeHTML } from "./format.js";
|
|
13
|
+
import { RelayRuntime } from "./relay-runtime.js";
|
|
14
|
+
import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
|
|
15
|
+
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
16
|
+
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
17
|
+
const options = parseOptions(process.argv.slice(2));
|
|
18
|
+
const auth = resolveDashboardAuth(options.host);
|
|
19
|
+
if (auth.publicBind && !auth.token && !(auth.user && auth.password)) {
|
|
20
|
+
throw new Error("Dashboard bound to 0.0.0.0 requires NORDRELAY_DASHBOARD_TOKEN or NORDRELAY_DASHBOARD_USER/PASSWORD.");
|
|
21
|
+
}
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
const runtime = new RelayRuntime(config);
|
|
24
|
+
const settings = new SettingsService(resolveDashboardEnvPath(options.home));
|
|
25
|
+
const server = createServer((req, res) => {
|
|
26
|
+
void handleRequest(req, res).catch((error) => {
|
|
27
|
+
sendJson(res, 500, { error: friendlyErrorText(error) });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
await new Promise((resolve) => server.listen(options.port, options.host, resolve));
|
|
31
|
+
console.log(`NordRelay dashboard: http://${options.host}:${options.port}/`);
|
|
32
|
+
process.once("SIGINT", () => shutdown());
|
|
33
|
+
process.once("SIGTERM", () => shutdown());
|
|
34
|
+
async function handleRequest(req, res) {
|
|
35
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
36
|
+
const queryToken = url.searchParams.get("token");
|
|
37
|
+
if (queryToken && isAuthorizedToken(queryToken) && !url.pathname.startsWith("/api/")) {
|
|
38
|
+
setAuthCookie(res, queryToken);
|
|
39
|
+
res.writeHead(302, { location: url.pathname || "/" });
|
|
40
|
+
res.end();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (url.pathname === "/api/auth" && req.method === "POST") {
|
|
44
|
+
await handleLogin(req, res);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (auth.required && !isAuthorizedRequest(req) && !isAuthorizedToken(queryToken ?? "")) {
|
|
48
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
49
|
+
sendText(res, 200, renderLoginPage(auth), "text/html; charset=utf-8");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") {
|
|
53
|
+
sendJson(res, 401, { error: "Authentication required" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
sendText(res, 401, "Authentication required\n", "text/plain; charset=utf-8");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (url.pathname === "/healthz") {
|
|
60
|
+
sendText(res, 200, "ok\n", "text/plain; charset=utf-8");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
64
|
+
sendText(res, 200, renderDashboardApp({ authRequired: auth.required }), "text/html; charset=utf-8");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (url.pathname === "/api/events" && req.method === "GET") {
|
|
68
|
+
handleEvents(req, res);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!url.pathname.startsWith("/api/")) {
|
|
72
|
+
sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await handleApi(req, res, url);
|
|
76
|
+
}
|
|
77
|
+
async function handleApi(req, res, url) {
|
|
78
|
+
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
79
|
+
sendJson(res, 200, {
|
|
80
|
+
auth: { required: auth.required, publicBind: auth.publicBind },
|
|
81
|
+
channels: listChannelDescriptors(),
|
|
82
|
+
agentAdapters: listAgentAdapterDescriptors(),
|
|
83
|
+
enabledAgents: enabledAgents(config),
|
|
84
|
+
controls: await runtime.controlOptions(),
|
|
85
|
+
status: await runtime.status(),
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (req.method === "GET" && url.pathname === "/api/control-options") {
|
|
90
|
+
sendJson(res, 200, await runtime.controlOptions());
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
94
|
+
sendJson(res, 200, await runtime.status());
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (req.method === "GET" && url.pathname === "/api/settings") {
|
|
98
|
+
sendJson(res, 200, await settings.snapshot());
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (req.method === "PATCH" && url.pathname === "/api/settings") {
|
|
102
|
+
const body = await readJsonBody(req);
|
|
103
|
+
sendJson(res, 200, await settings.update(objectRecord(body?.settings)));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (req.method === "GET" && url.pathname === "/api/snapshot") {
|
|
107
|
+
sendJson(res, 200, await runtime.snapshot());
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
111
|
+
sendJson(res, 200, await runtime.listSessionsPage(numberParam(url, "page", 1), numberParam(url, "limit", 50), url.searchParams.get("query") ?? ""));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (req.method === "POST" && url.pathname === "/api/agent") {
|
|
115
|
+
const body = await readJsonBody(req);
|
|
116
|
+
const agentId = stringField(body, "agentId");
|
|
117
|
+
if (!isAgentId(agentId)) {
|
|
118
|
+
throw new Error(`Invalid agent: ${agentId}`);
|
|
119
|
+
}
|
|
120
|
+
sendJson(res, 200, { session: await runtime.setAgent(agentId) });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (req.method === "POST" && url.pathname === "/api/sessions/new") {
|
|
124
|
+
const body = await readJsonBody(req);
|
|
125
|
+
sendJson(res, 200, {
|
|
126
|
+
session: await runtime.newSession({
|
|
127
|
+
agentId: parseAgentId(optionalStringField(body, "agentId")),
|
|
128
|
+
workspace: optionalStringField(body, "workspace"),
|
|
129
|
+
model: optionalStringField(body, "model"),
|
|
130
|
+
reasoningEffort: optionalStringField(body, "reasoningEffort"),
|
|
131
|
+
launchProfileId: optionalStringField(body, "launchProfileId"),
|
|
132
|
+
fastMode: optionalBooleanField(body, "fastMode"),
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (req.method === "POST" && url.pathname === "/api/sessions/switch") {
|
|
138
|
+
const body = await readJsonBody(req);
|
|
139
|
+
sendJson(res, 200, { session: await runtime.switchSession(stringField(body, "threadId")) });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
|
|
143
|
+
const body = await readJsonBody(req);
|
|
144
|
+
sendJson(res, 200, { session: await runtime.attachSession(stringField(body, "threadId")) });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (req.method === "GET" && url.pathname === "/api/models") {
|
|
148
|
+
sendJson(res, 200, { models: await runtime.listModels() });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (req.method === "POST" && url.pathname === "/api/session/model") {
|
|
152
|
+
const body = await readJsonBody(req);
|
|
153
|
+
sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
|
|
157
|
+
const body = await readJsonBody(req);
|
|
158
|
+
sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (req.method === "POST" && url.pathname === "/api/session/fast") {
|
|
162
|
+
const body = await readJsonBody(req);
|
|
163
|
+
sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (req.method === "POST" && url.pathname === "/api/session/launch") {
|
|
167
|
+
const body = await readJsonBody(req);
|
|
168
|
+
sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (req.method === "POST" && url.pathname === "/api/prompt") {
|
|
172
|
+
const body = await readJsonBody(req);
|
|
173
|
+
sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
|
|
177
|
+
const body = await readJsonBody(req);
|
|
178
|
+
sendJson(res, 202, await runtime.sendUploadPrompt({
|
|
179
|
+
text: optionalStringField(body, "text"),
|
|
180
|
+
files: parseUploadFiles(body.files),
|
|
181
|
+
}));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (req.method === "POST" && url.pathname === "/api/abort") {
|
|
185
|
+
await runtime.abort();
|
|
186
|
+
sendJson(res, 200, { ok: true });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (req.method === "POST" && url.pathname === "/api/handback") {
|
|
190
|
+
sendJson(res, 200, await runtime.handback());
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (req.method === "GET" && url.pathname === "/api/queue") {
|
|
194
|
+
sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (req.method === "POST" && url.pathname === "/api/queue") {
|
|
198
|
+
const body = await readJsonBody(req);
|
|
199
|
+
sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (req.method === "GET" && url.pathname === "/api/chat/history") {
|
|
203
|
+
sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
|
|
207
|
+
sendJson(res, 200, await runtime.clearChatHistory());
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (req.method === "GET" && url.pathname === "/api/activity") {
|
|
211
|
+
sendJson(res, 200, {
|
|
212
|
+
events: runtime.activity({
|
|
213
|
+
limit: numberParam(url, "limit", 100),
|
|
214
|
+
source: (url.searchParams.get("source") || "all"),
|
|
215
|
+
status: (url.searchParams.get("status") || "all"),
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (req.method === "GET" && url.pathname === "/api/artifacts") {
|
|
221
|
+
sendJson(res, 200, { reports: await runtime.artifacts() });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
|
|
225
|
+
sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
|
|
229
|
+
const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
|
|
230
|
+
if (!bundle) {
|
|
231
|
+
sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
sendFile(res, bundle.path, bundle.name);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
|
|
238
|
+
const turnId = requiredSearch(url, "turnId");
|
|
239
|
+
const relativePath = requiredSearch(url, "path");
|
|
240
|
+
const report = await runtime.artifact(turnId);
|
|
241
|
+
const artifact = report?.artifacts.find((candidate) => candidate.relativePath === relativePath);
|
|
242
|
+
if (!artifact) {
|
|
243
|
+
sendJson(res, 404, { error: "Artifact not found" });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
sendFile(res, artifact.localPath, artifact.name);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
|
|
250
|
+
const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
|
|
251
|
+
if (!preview) {
|
|
252
|
+
sendJson(res, 404, { error: "Artifact not found" });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
sendJson(res, 200, preview);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (req.method === "GET" && url.pathname === "/api/logs") {
|
|
259
|
+
sendJson(res, 200, await runtime.logs(url.searchParams.get("target") || "connector", numberParam(url, "lines", 120)));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (req.method === "GET" && url.pathname === "/api/diagnostics") {
|
|
263
|
+
sendJson(res, 200, await runtime.diagnostics());
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (req.method === "POST" && url.pathname === "/api/runtime/restart") {
|
|
267
|
+
sendJson(res, 202, runtime.restartConnector());
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
sendJson(res, 404, { error: "Unknown endpoint" });
|
|
271
|
+
}
|
|
272
|
+
function handleEvents(req, res) {
|
|
273
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
274
|
+
const token = url.searchParams.get("token");
|
|
275
|
+
if (auth.required && !(isAuthorizedRequest(req) || (token && isAuthorizedToken(token)))) {
|
|
276
|
+
sendJson(res, 401, { error: "Authentication required" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
res.writeHead(200, {
|
|
280
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
281
|
+
"cache-control": "no-cache, no-transform",
|
|
282
|
+
connection: "keep-alive",
|
|
283
|
+
});
|
|
284
|
+
const send = (event) => {
|
|
285
|
+
res.write(`event: ${event.type}\n`);
|
|
286
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
287
|
+
};
|
|
288
|
+
const unsubscribe = runtime.subscribe(send);
|
|
289
|
+
const heartbeat = setInterval(() => {
|
|
290
|
+
res.write(": heartbeat\n\n");
|
|
291
|
+
}, 25_000);
|
|
292
|
+
heartbeat.unref?.();
|
|
293
|
+
req.on("close", () => {
|
|
294
|
+
clearInterval(heartbeat);
|
|
295
|
+
unsubscribe();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async function handleLogin(req, res) {
|
|
299
|
+
const body = await readJsonBody(req);
|
|
300
|
+
const token = optionalStringField(body, "token");
|
|
301
|
+
const user = optionalStringField(body, "user");
|
|
302
|
+
const password = optionalStringField(body, "password");
|
|
303
|
+
if (token && isAuthorizedToken(token)) {
|
|
304
|
+
setAuthCookie(res, token);
|
|
305
|
+
sendJson(res, 200, { ok: true, mode: "token" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (user && password && isAuthorizedBasic(user, password)) {
|
|
309
|
+
setBasicCookie(res, user, password);
|
|
310
|
+
sendJson(res, 200, { ok: true, mode: "basic" });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
sendJson(res, 401, { error: "Invalid dashboard credentials" });
|
|
314
|
+
}
|
|
315
|
+
function parseOptions(argv) {
|
|
316
|
+
let host = process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
|
|
317
|
+
let port = Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10);
|
|
318
|
+
let home = process.env.NORDRELAY_HOME || DEFAULT_HOME;
|
|
319
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
320
|
+
const arg = argv[index];
|
|
321
|
+
if (arg === "--host")
|
|
322
|
+
host = requireArg(argv, ++index, arg);
|
|
323
|
+
else if (arg === "--port")
|
|
324
|
+
port = Number.parseInt(requireArg(argv, ++index, arg), 10);
|
|
325
|
+
else if (arg === "--home")
|
|
326
|
+
home = requireArg(argv, ++index, arg);
|
|
327
|
+
}
|
|
328
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
329
|
+
throw new Error("Dashboard port must be a positive number.");
|
|
330
|
+
}
|
|
331
|
+
return { host, port, home };
|
|
332
|
+
}
|
|
333
|
+
function resolveDashboardAuth(host) {
|
|
334
|
+
const token = optionalEnv("NORDRELAY_DASHBOARD_TOKEN");
|
|
335
|
+
const user = optionalEnv("NORDRELAY_DASHBOARD_USER");
|
|
336
|
+
const password = optionalEnv("NORDRELAY_DASHBOARD_PASSWORD");
|
|
337
|
+
const publicBind = isPublicBindHost(host);
|
|
338
|
+
return {
|
|
339
|
+
required: publicBind || Boolean(token || (user && password)),
|
|
340
|
+
publicBind,
|
|
341
|
+
token,
|
|
342
|
+
user,
|
|
343
|
+
password,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function isPublicBindHost(host) {
|
|
347
|
+
return host === "0.0.0.0" || host === "::" || host === "";
|
|
348
|
+
}
|
|
349
|
+
function isAuthorizedRequest(req) {
|
|
350
|
+
if (!auth.required) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
const header = req.headers.authorization;
|
|
354
|
+
if (header?.startsWith("Bearer ") && isAuthorizedToken(header.slice("Bearer ".length).trim())) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
if (header?.startsWith("Basic ")) {
|
|
358
|
+
const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString("utf8");
|
|
359
|
+
const [user, ...passwordParts] = decoded.split(":");
|
|
360
|
+
if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const cookies = parseCookies(req.headers.cookie ?? "");
|
|
365
|
+
if (cookies.nrdash && isAuthorizedToken(cookies.nrdash)) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
if (cookies.nrdash_basic) {
|
|
369
|
+
const decoded = Buffer.from(cookies.nrdash_basic, "base64").toString("utf8");
|
|
370
|
+
const [user, ...passwordParts] = decoded.split(":");
|
|
371
|
+
if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
function isAuthorizedToken(token) {
|
|
378
|
+
return Boolean(auth.token && constantTimeEqual(token, auth.token));
|
|
379
|
+
}
|
|
380
|
+
function isAuthorizedBasic(user, password) {
|
|
381
|
+
return Boolean(auth.user && auth.password && constantTimeEqual(user, auth.user) && constantTimeEqual(password, auth.password));
|
|
382
|
+
}
|
|
383
|
+
function constantTimeEqual(left, right) {
|
|
384
|
+
const leftBuffer = Buffer.from(left);
|
|
385
|
+
const rightBuffer = Buffer.from(right);
|
|
386
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
return cryptoTimingSafeEqual(leftBuffer, rightBuffer);
|
|
390
|
+
}
|
|
391
|
+
function cryptoTimingSafeEqual(left, right) {
|
|
392
|
+
let diff = 0;
|
|
393
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
394
|
+
diff |= left[index] ^ right[index];
|
|
395
|
+
}
|
|
396
|
+
return diff === 0;
|
|
397
|
+
}
|
|
398
|
+
function setAuthCookie(res, token) {
|
|
399
|
+
res.setHeader("set-cookie", `nrdash=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
|
|
400
|
+
}
|
|
401
|
+
function setBasicCookie(res, user, password) {
|
|
402
|
+
const value = Buffer.from(`${user}:${password}`).toString("base64");
|
|
403
|
+
res.setHeader("set-cookie", `nrdash_basic=${encodeURIComponent(value)}; HttpOnly; SameSite=Strict; Path=/`);
|
|
404
|
+
}
|
|
405
|
+
function parseCookies(cookieHeader) {
|
|
406
|
+
const cookies = {};
|
|
407
|
+
for (const part of cookieHeader.split(";")) {
|
|
408
|
+
const [key, ...valueParts] = part.trim().split("=");
|
|
409
|
+
if (key)
|
|
410
|
+
cookies[key] = decodeURIComponent(valueParts.join("=") ?? "");
|
|
411
|
+
}
|
|
412
|
+
return cookies;
|
|
413
|
+
}
|
|
414
|
+
async function readJsonBody(req) {
|
|
415
|
+
const chunks = [];
|
|
416
|
+
for await (const chunk of req) {
|
|
417
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
418
|
+
}
|
|
419
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
420
|
+
if (!text) {
|
|
421
|
+
return {};
|
|
422
|
+
}
|
|
423
|
+
return JSON.parse(text);
|
|
424
|
+
}
|
|
425
|
+
function sendJson(res, status, value) {
|
|
426
|
+
res.writeHead(status, JSON_HEADERS);
|
|
427
|
+
res.end(`${JSON.stringify(value)}\n`);
|
|
428
|
+
}
|
|
429
|
+
function sendText(res, status, text, contentType) {
|
|
430
|
+
res.writeHead(status, { "content-type": contentType });
|
|
431
|
+
res.end(text);
|
|
432
|
+
}
|
|
433
|
+
function sendFile(res, filePath, filename) {
|
|
434
|
+
res.writeHead(200, {
|
|
435
|
+
"content-type": "application/octet-stream",
|
|
436
|
+
"content-disposition": `attachment; filename="${filename.replace(/"/g, "")}"`,
|
|
437
|
+
});
|
|
438
|
+
createReadStream(filePath).pipe(res);
|
|
439
|
+
}
|
|
440
|
+
function stringField(value, key) {
|
|
441
|
+
const field = value[key];
|
|
442
|
+
if (typeof field !== "string" || !field.trim()) {
|
|
443
|
+
throw new Error(`${key} is required`);
|
|
444
|
+
}
|
|
445
|
+
return field.trim();
|
|
446
|
+
}
|
|
447
|
+
function optionalStringField(value, key) {
|
|
448
|
+
const field = value[key];
|
|
449
|
+
return typeof field === "string" && field.trim() ? field.trim() : undefined;
|
|
450
|
+
}
|
|
451
|
+
function optionalBooleanField(value, key) {
|
|
452
|
+
const field = value[key];
|
|
453
|
+
return typeof field === "boolean" ? field : undefined;
|
|
454
|
+
}
|
|
455
|
+
function parseAgentId(value) {
|
|
456
|
+
if (!value) {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
if (!isAgentId(value)) {
|
|
460
|
+
throw new Error(`Invalid agent: ${value}`);
|
|
461
|
+
}
|
|
462
|
+
return value;
|
|
463
|
+
}
|
|
464
|
+
function objectRecord(value) {
|
|
465
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
466
|
+
return {};
|
|
467
|
+
}
|
|
468
|
+
return value;
|
|
469
|
+
}
|
|
470
|
+
function parseUploadFiles(value) {
|
|
471
|
+
if (!Array.isArray(value)) {
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
return value.map((item, index) => {
|
|
475
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
476
|
+
throw new Error(`files[${index}] must be an object`);
|
|
477
|
+
}
|
|
478
|
+
const record = item;
|
|
479
|
+
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : `upload-${index + 1}`;
|
|
480
|
+
const mimeType = typeof record.mimeType === "string" ? record.mimeType.trim() : undefined;
|
|
481
|
+
const dataBase64 = typeof record.dataBase64 === "string" ? record.dataBase64 : "";
|
|
482
|
+
if (!dataBase64) {
|
|
483
|
+
throw new Error(`files[${index}].dataBase64 is required`);
|
|
484
|
+
}
|
|
485
|
+
return { name, mimeType, data: Buffer.from(stripDataUrlPrefix(dataBase64), "base64") };
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
function stripDataUrlPrefix(value) {
|
|
489
|
+
const comma = value.indexOf(",");
|
|
490
|
+
return value.startsWith("data:") && comma !== -1 ? value.slice(comma + 1) : value;
|
|
491
|
+
}
|
|
492
|
+
function numberParam(url, key, fallback) {
|
|
493
|
+
const value = Number(url.searchParams.get(key));
|
|
494
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
495
|
+
}
|
|
496
|
+
function requiredSearch(url, key) {
|
|
497
|
+
const value = url.searchParams.get(key);
|
|
498
|
+
if (!value) {
|
|
499
|
+
throw new Error(`${key} is required`);
|
|
500
|
+
}
|
|
501
|
+
return value;
|
|
502
|
+
}
|
|
503
|
+
function optionalEnv(key) {
|
|
504
|
+
const value = process.env[key]?.trim();
|
|
505
|
+
return value || undefined;
|
|
506
|
+
}
|
|
507
|
+
function requireArg(argv, index, flag) {
|
|
508
|
+
const value = argv[index];
|
|
509
|
+
if (!value || value.startsWith("--")) {
|
|
510
|
+
throw new Error(`${flag} requires a value`);
|
|
511
|
+
}
|
|
512
|
+
return value;
|
|
513
|
+
}
|
|
514
|
+
function shutdown() {
|
|
515
|
+
runtime.dispose();
|
|
516
|
+
server.close(() => process.exit(0));
|
|
517
|
+
}
|
|
518
|
+
function renderLoginPage(currentAuth) {
|
|
519
|
+
return `<!doctype html>
|
|
520
|
+
<html lang="en">
|
|
521
|
+
<head>
|
|
522
|
+
<meta charset="utf-8">
|
|
523
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
524
|
+
<title>NordRelay Login</title>
|
|
525
|
+
<style>
|
|
526
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
|
|
527
|
+
form{width:min(420px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
|
|
528
|
+
h1{font-size:24px;margin:0 0 8px}
|
|
529
|
+
p{color:#5d665d;margin:0 0 18px}
|
|
530
|
+
label{display:block;font-size:13px;color:#4b544d;margin:14px 0 6px}
|
|
531
|
+
input{box-sizing:border-box;width:100%;height:40px;border:1px solid #cfd6ce;border-radius:6px;padding:0 10px;font:inherit}
|
|
532
|
+
button{margin-top:18px;width:100%;height:42px;border:0;border-radius:6px;background:#205c43;color:white;font-weight:650;cursor:pointer}
|
|
533
|
+
.error{color:#9b1c1c;min-height:22px;margin-top:12px}
|
|
534
|
+
</style>
|
|
535
|
+
</head>
|
|
536
|
+
<body>
|
|
537
|
+
<form id="login">
|
|
538
|
+
<h1>NordRelay Dashboard</h1>
|
|
539
|
+
<p>${currentAuth.publicBind ? "Remote dashboard access requires authentication." : "Authentication required."}</p>
|
|
540
|
+
${currentAuth.token ? '<label>Token</label><input id="token" name="token" type="password" autocomplete="current-password">' : ""}
|
|
541
|
+
${currentAuth.user ? '<label>User</label><input id="user" name="user" autocomplete="username"><label>Password</label><input id="password" name="password" type="password" autocomplete="current-password">' : ""}
|
|
542
|
+
<button>Sign in</button>
|
|
543
|
+
<div class="error" id="error"></div>
|
|
544
|
+
</form>
|
|
545
|
+
<script>
|
|
546
|
+
document.getElementById('login').addEventListener('submit', async (event) => {
|
|
547
|
+
event.preventDefault();
|
|
548
|
+
const payload = {
|
|
549
|
+
token: document.getElementById('token')?.value || undefined,
|
|
550
|
+
user: document.getElementById('user')?.value || undefined,
|
|
551
|
+
password: document.getElementById('password')?.value || undefined,
|
|
552
|
+
};
|
|
553
|
+
const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
|
|
554
|
+
if (!res.ok) {
|
|
555
|
+
document.getElementById('error').textContent = 'Invalid credentials';
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (payload.token) localStorage.setItem('nordrelayDashboardToken', payload.token);
|
|
559
|
+
location.href = '/';
|
|
560
|
+
});
|
|
561
|
+
</script>
|
|
562
|
+
</body>
|
|
563
|
+
</html>`;
|
|
564
|
+
}
|
|
565
|
+
function renderDashboardApp(options) {
|
|
566
|
+
return `<!doctype html>
|
|
567
|
+
<html lang="en">
|
|
568
|
+
<head>
|
|
569
|
+
<meta charset="utf-8">
|
|
570
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
571
|
+
<title>NordRelay Dashboard</title>
|
|
572
|
+
<script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
|
|
573
|
+
<style>${dashboardCss()}</style>
|
|
574
|
+
</head>
|
|
575
|
+
<body>
|
|
576
|
+
<div class="app">
|
|
577
|
+
<aside class="sidebar" id="sidebar">
|
|
578
|
+
<div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
|
|
579
|
+
<nav>
|
|
580
|
+
<button data-page="overview" class="active">Overview</button>
|
|
581
|
+
<button data-page="chat">Chat</button>
|
|
582
|
+
<button data-page="sessions">Sessions</button>
|
|
583
|
+
<button data-page="queue">Queue</button>
|
|
584
|
+
<button data-page="activity">Activity</button>
|
|
585
|
+
<button data-page="artifacts">Artifacts</button>
|
|
586
|
+
<button data-page="settings">Settings</button>
|
|
587
|
+
<button data-page="logs">Logs</button>
|
|
588
|
+
<button data-page="diagnostics">Diagnostics</button>
|
|
589
|
+
</nav>
|
|
590
|
+
</aside>
|
|
591
|
+
<main>
|
|
592
|
+
<header>
|
|
593
|
+
<button class="menu" id="menuBtn">Menu</button>
|
|
594
|
+
<div>
|
|
595
|
+
<h1 id="pageTitle">Overview</h1>
|
|
596
|
+
<p id="sessionLine">Loading session...</p>
|
|
597
|
+
</div>
|
|
598
|
+
<div class="header-actions">
|
|
599
|
+
<select id="agentSelect"></select>
|
|
600
|
+
<button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
|
|
601
|
+
<button id="refreshBtn">Refresh</button>
|
|
602
|
+
</div>
|
|
603
|
+
</header>
|
|
604
|
+
|
|
605
|
+
<section class="page active" id="page-overview">
|
|
606
|
+
<div class="metrics" id="metrics"></div>
|
|
607
|
+
<div class="stack">
|
|
608
|
+
<div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
|
|
609
|
+
<div class="panel"><h2>Adapters</h2><div id="adapters"></div></div>
|
|
610
|
+
</div>
|
|
611
|
+
</section>
|
|
612
|
+
|
|
613
|
+
<section class="page" id="page-chat">
|
|
614
|
+
<div class="chat-layout">
|
|
615
|
+
<div class="panel chat-panel">
|
|
616
|
+
<div class="chat-toolbar">
|
|
617
|
+
<button id="newSessionBtn">New session</button>
|
|
618
|
+
<button id="clearChatBtn" class="secondary">Clear history</button>
|
|
619
|
+
<button id="abortBtn">Abort</button>
|
|
620
|
+
<button id="handbackBtn">Handback</button>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="control-grid" id="sessionControls"></div>
|
|
623
|
+
<div id="messages" class="messages"></div>
|
|
624
|
+
<form id="promptForm" class="composer">
|
|
625
|
+
<div class="composer-fields">
|
|
626
|
+
<textarea id="promptInput" placeholder="Send a message to the active coding agent..." rows="3"></textarea>
|
|
627
|
+
<div class="attachment-row">
|
|
628
|
+
<label class="file-button" for="fileInput">Attach files</label>
|
|
629
|
+
<input id="fileInput" type="file" multiple>
|
|
630
|
+
<span id="fileSummary">No files selected</span>
|
|
631
|
+
<button type="button" id="clearFilesBtn" class="secondary">Clear</button>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
<button>Send</button>
|
|
635
|
+
</form>
|
|
636
|
+
</div>
|
|
637
|
+
<div class="panel side-panel"><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
|
|
638
|
+
</div>
|
|
639
|
+
</section>
|
|
640
|
+
|
|
641
|
+
<section class="page" id="page-sessions">
|
|
642
|
+
<div class="panel">
|
|
643
|
+
<div class="sessions-toolbar">
|
|
644
|
+
<div class="row search-row"><input id="sessionSearch" placeholder="Search sessions"><button id="sessionSearchBtn">Search</button></div>
|
|
645
|
+
<div class="row attach-row"><input id="attachInput" placeholder="Thread ID to attach/switch"><button id="attachBtn">Attach</button></div>
|
|
646
|
+
</div>
|
|
647
|
+
<div id="sessionsList" class="list"></div>
|
|
648
|
+
<div id="sessionsPager" class="pager"></div>
|
|
649
|
+
</div>
|
|
650
|
+
</section>
|
|
651
|
+
|
|
652
|
+
<section class="page" id="page-queue">
|
|
653
|
+
<div class="panel">
|
|
654
|
+
<div class="row"><button data-queue="pause">Pause</button><button data-queue="resume">Resume</button><button data-queue="clear" class="danger">Clear</button><span id="queueStatus"></span></div>
|
|
655
|
+
<div id="queueList" class="list"></div>
|
|
656
|
+
</div>
|
|
657
|
+
</section>
|
|
658
|
+
|
|
659
|
+
<section class="page" id="page-activity">
|
|
660
|
+
<div class="panel">
|
|
661
|
+
<div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="cli">CLI</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button></div>
|
|
662
|
+
<div id="activityList" class="list"></div>
|
|
663
|
+
</div>
|
|
664
|
+
</section>
|
|
665
|
+
|
|
666
|
+
<section class="page" id="page-artifacts">
|
|
667
|
+
<div class="panel">
|
|
668
|
+
<div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button></div>
|
|
669
|
+
<div id="artifactList" class="list"></div>
|
|
670
|
+
<div id="artifactPreview" class="preview"></div>
|
|
671
|
+
</div>
|
|
672
|
+
</section>
|
|
673
|
+
|
|
674
|
+
<section class="page" id="page-settings">
|
|
675
|
+
<div class="panel">
|
|
676
|
+
<div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
|
|
677
|
+
<div id="settingsTabs" class="tabs"></div>
|
|
678
|
+
<div id="settingsForm" class="settings-grid"></div>
|
|
679
|
+
</div>
|
|
680
|
+
</section>
|
|
681
|
+
|
|
682
|
+
<section class="page" id="page-logs">
|
|
683
|
+
<div class="panel">
|
|
684
|
+
<div class="row"><select id="logTarget"><option value="connector">Connector</option><option value="update">Update</option></select><select id="logLevel"><option value="all">All levels</option><option value="ERROR">Error</option><option value="WARN">Warn</option><option value="INFO">Info</option></select><input id="logSearch" placeholder="Search logs"><input id="logLines" type="number" value="120" min="1" max="300"><label class="checkbox"><input id="logAutoRefresh" type="checkbox"> Auto</label><button id="loadLogsBtn">Load logs</button><button id="downloadLogsBtn" class="secondary">Download</button></div>
|
|
685
|
+
<pre id="logs"></pre>
|
|
686
|
+
</div>
|
|
687
|
+
</section>
|
|
688
|
+
|
|
689
|
+
<section class="page" id="page-diagnostics">
|
|
690
|
+
<div class="panel"><div id="diagnostics" class="list"></div></div>
|
|
691
|
+
</section>
|
|
692
|
+
|
|
693
|
+
<footer>
|
|
694
|
+
<span id="footerVersion">NordRelay</span>
|
|
695
|
+
<span id="footerHealth">Health: loading</span>
|
|
696
|
+
<span>Dashboard bind: ${escapeHTML(options.authRequired ? "authenticated" : "local")}</span>
|
|
697
|
+
</footer>
|
|
698
|
+
</main>
|
|
699
|
+
</div>
|
|
700
|
+
<dialog id="newSessionDialog">
|
|
701
|
+
<form method="dialog" id="newSessionForm">
|
|
702
|
+
<h2>New Session</h2>
|
|
703
|
+
<div class="form-grid">
|
|
704
|
+
<label>Agent<select id="newAgent"></select></label>
|
|
705
|
+
<label>Workspace<input id="newWorkspace" list="workspaceOptions" placeholder="Current workspace"></label>
|
|
706
|
+
<label>Model<select id="newModel"></select></label>
|
|
707
|
+
<label id="newReasoningWrap">Reasoning<select id="newReasoning"></select></label>
|
|
708
|
+
<label id="newLaunchWrap">Launch profile<select id="newLaunch"></select></label>
|
|
709
|
+
<label id="newFastWrap" class="checkbox"><input id="newFast" type="checkbox"> Fast mode</label>
|
|
710
|
+
</div>
|
|
711
|
+
<datalist id="workspaceOptions"></datalist>
|
|
712
|
+
<div class="row dialog-actions"><button type="button" id="cancelSessionBtn" class="secondary">Cancel</button><button id="createSessionBtn" value="default">Create session</button></div>
|
|
713
|
+
</form>
|
|
714
|
+
</dialog>
|
|
715
|
+
<div id="toast"></div>
|
|
716
|
+
<script>${dashboardJs()}</script>
|
|
717
|
+
</body>
|
|
718
|
+
</html>`;
|
|
719
|
+
}
|
|
720
|
+
function dashboardCss() {
|
|
721
|
+
return `
|
|
722
|
+
:root{color-scheme:light;--bg:#f4f6f2;--surface:#ffffff;--surface-soft:#fbfcf8;--text:#18201b;--muted:#5d675f;--border:#dce3d9;--border-soft:#e7ede4;--sidebar:#17251d;--sidebar-text:#f4f8f2;--sidebar-muted:#aebcaf;--accent:#235c42;--accent-strong:#17452f;--accent-soft:#dff5e8;--warn:#fff7da;--danger:#9b1c1c;--pre:#111812;--pre-text:#f3f7ef;--shadow:0 8px 24px rgba(24,32,27,.04);--link:#1d6a4c}
|
|
723
|
+
:root[data-theme="dark"]{color-scheme:dark;--bg:#101411;--surface:#171d19;--surface-soft:#1d251f;--text:#edf4ee;--muted:#a7b3aa;--border:#2d3830;--border-soft:#263128;--sidebar:#0c120f;--sidebar-text:#edf7ef;--sidebar-muted:#8da091;--accent:#4fa876;--accent-strong:#64bd89;--accent-soft:#173d2a;--warn:#3b3216;--danger:#cc4b4b;--pre:#070a08;--pre-text:#e8f1ea;--shadow:0 10px 28px rgba(0,0,0,.22);--link:#75c99a}
|
|
724
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}.app{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.sidebar{background:var(--sidebar);color:var(--sidebar-text);padding:18px;display:flex;flex-direction:column;gap:22px}.brand{display:flex;align-items:center;gap:12px}.mark{display:grid;place-items:center;width:38px;height:38px;border-radius:8px;background:#d7ffe5;color:#173d29;font-weight:800}.brand small{display:block;color:var(--sidebar-muted)}nav{display:flex;flex-direction:column;gap:6px}nav button,.menu{border:0;border-radius:6px;padding:10px 12px;background:transparent;color:inherit;text-align:left;font:inherit;cursor:pointer}nav button.active,nav button:hover{background:color-mix(in srgb,var(--accent) 35%,transparent)}main{min-width:0;display:flex;flex-direction:column}header{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;gap:16px;align-items:center;padding:16px 22px;background:color-mix(in srgb,var(--surface) 92%,transparent);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}h1{font-size:24px;margin:0}h2{font-size:16px;margin:0 0 12px}p{margin:4px 0 0;color:var(--muted)}a{color:var(--link)}.header-actions,.row,.chat-toolbar,.attachment-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.menu{display:none;background:var(--surface-soft);color:var(--text)}.page{display:none;padding:22px}.page.active{display:block}.stack{display:flex;flex-direction:column;gap:16px}.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:16px}.metric,.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;box-shadow:var(--shadow)}.metric .label{font-size:12px;text-transform:uppercase;color:var(--muted)}.metric .value{font-size:22px;font-weight:750;margin-top:4px;overflow:hidden;text-overflow:ellipsis}button,select,input,textarea{border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font:inherit}button{height:36px;padding:0 12px;background:var(--accent);color:white;border-color:var(--accent);cursor:pointer}button:hover{background:var(--accent-strong)}button.secondary{background:var(--surface);color:var(--text)}input,select{height:36px;padding:0 10px}textarea{width:100%;padding:10px;resize:vertical}.chat-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;align-items:start}.chat-panel{min-height:calc(100vh - 170px);display:flex;flex-direction:column}.control-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:12px 0}.control-grid label,.form-grid label{display:grid;gap:5px;font-size:12px;color:var(--muted)}.messages{flex:1;min-height:360px;overflow:auto;border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.message{margin:0 0 12px;padding:10px 12px;border-radius:8px;max-width:92%;white-space:pre-wrap;word-break:break-word}.message.user{margin-left:auto;background:var(--accent-soft)}.message.agent{background:color-mix(in srgb,var(--surface-soft) 80%,var(--border))}.message.system{background:var(--warn)}.composer{display:grid;grid-template-columns:1fr auto;gap:10px;margin-top:12px}.composer-fields{min-width:0}.composer button{height:auto;min-width:90px}.attachment-row{margin-top:8px;color:var(--muted);font-size:13px}.file-button{display:inline-flex;align-items:center;height:34px;padding:0 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);cursor:pointer}input[type=file]{display:none}.sessions-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.sessions-toolbar .search-row{flex:1 1 320px}.sessions-toolbar .attach-row{flex:1 1 360px;justify-content:flex-end;margin-left:auto}.sessions-toolbar input{min-width:220px}.copy-id{height:auto;padding:0;border:0;background:transparent;color:var(--link);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}.copy-id:hover{background:transparent;text-decoration:underline}.side-panel{max-height:calc(100vh - 126px);display:flex;flex-direction:column}.tool-stream{display:flex;flex-direction:column;gap:8px;overflow:auto;max-height:calc(100vh - 190px);padding-right:4px}.tool{border:1px solid var(--border-soft);border-radius:6px;padding:8px;background:var(--surface-soft);white-space:pre-wrap;word-break:break-word}.list{display:flex;flex-direction:column;gap:8px;margin-top:12px}.item{border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.item strong{display:block;overflow-wrap:anywhere}.item small{display:block;color:var(--muted);overflow-wrap:anywhere}.queue-item{cursor:grab}.queue-item.dragging{opacity:.55}.badge{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:2px 8px;color:var(--muted);font-size:12px}.preview{margin-top:12px}.preview img{max-width:100%;border:1px solid var(--border);border-radius:8px;background:var(--surface)}.settings-grid{display:block}.setting{border:1px solid var(--border-soft);border-radius:8px;padding:12px;margin-bottom:10px;background:var(--surface-soft)}.setting label{display:block;font-size:13px;font-weight:700;margin-bottom:6px}.setting small{display:block;color:var(--muted);margin-top:6px}.setting input,.setting textarea,.setting select{width:100%}.setting-error{color:var(--danger);font-size:12px;margin-top:6px}.checkbox{display:inline-flex!important;grid-template-columns:auto 1fr!important;align-items:center;gap:8px}.checkbox input{height:auto;width:auto}.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.tabs button{background:var(--surface);color:var(--text);border-color:var(--border);height:34px}.tabs button.active{background:var(--accent);color:white;border-color:var(--accent)}.pager{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px;color:var(--muted)}.pager-actions{display:flex;gap:8px}.pager button:disabled{opacity:.45;cursor:not-allowed}pre{white-space:pre-wrap;word-break:break-word;background:var(--pre);color:var(--pre-text);border-radius:8px;padding:14px;overflow:auto}footer{margin-top:auto;display:flex;gap:18px;flex-wrap:wrap;padding:14px 22px;border-top:1px solid var(--border);color:var(--muted);background:var(--surface)}dialog{border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);width:min(720px,calc(100vw - 28px));padding:18px;box-shadow:0 18px 70px rgba(0,0,0,.22)}dialog::backdrop{background:rgba(0,0,0,.35)}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}.dialog-actions{justify-content:flex-end;margin-top:16px}#toast{position:fixed;right:18px;bottom:18px;display:none;background:var(--accent);color:white;border-radius:8px;padding:12px 14px;max-width:360px}.danger{background:var(--danger);border-color:var(--danger);color:white}@media(max-width:860px){.app{display:block}.sidebar{position:fixed;inset:0 auto 0 0;width:270px;transform:translateX(-100%);transition:.18s transform;z-index:20}.sidebar.open{transform:translateX(0)}.menu{display:inline-block}.header-actions{justify-content:flex-end}.page{padding:14px}.chat-layout{grid-template-columns:1fr}.composer{grid-template-columns:1fr}.composer button{height:40px}.side-panel{order:-1;max-height:360px}.tool-stream{max-height:300px}header{align-items:flex-start}.metrics{grid-template-columns:1fr 1fr}}@media(max-width:560px){.metrics{grid-template-columns:1fr}.row{align-items:stretch}.row>*{width:100%}header{display:grid;grid-template-columns:auto 1fr}.header-actions{grid-column:1/3}.message{max-width:100%}.pager{align-items:stretch}.pager-actions,.pager button{width:100%}.attachment-row>*,.sessions-toolbar,.sessions-toolbar .row,.sessions-toolbar input,.sessions-toolbar button{width:100%}.sessions-toolbar .attach-row{margin-left:0;justify-content:stretch}}
|
|
725
|
+
`;
|
|
726
|
+
}
|
|
727
|
+
function dashboardJs() {
|
|
728
|
+
return `
|
|
729
|
+
const token = localStorage.getItem('nordrelayDashboardToken') || '';
|
|
730
|
+
const state = { snapshot:null, controls:null, enabledAgents:[], settings:[], currentPage:'overview', settingsGroup:null, logsPlain:'', logTimer:null, toastTimer:null, cliStatusActive:false };
|
|
731
|
+
const authHeaders = () => token ? { authorization: 'Bearer ' + token } : {};
|
|
732
|
+
async function api(path, options={}) {
|
|
733
|
+
const headers = { ...(options.body ? {'content-type':'application/json'} : {}), ...authHeaders(), ...(options.headers||{}) };
|
|
734
|
+
const res = await fetch(path, { ...options, headers });
|
|
735
|
+
if (res.status === 401) { location.reload(); return; }
|
|
736
|
+
const text = await res.text();
|
|
737
|
+
const data = text ? JSON.parse(text) : {};
|
|
738
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
739
|
+
return data;
|
|
740
|
+
}
|
|
741
|
+
function toast(msg,options={}){const el=document.getElementById('toast');el.textContent=msg;el.style.display='block';if(state.toastTimer)clearTimeout(state.toastTimer);state.toastTimer=null;if(!options.sticky){state.toastTimer=setTimeout(()=>{el.style.display='none';state.toastTimer=null},options.duration||3500)}}
|
|
742
|
+
function esc(s){return String(s??'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]))}
|
|
743
|
+
function attr(s){return esc(s).replace(/"/g,'"')}
|
|
744
|
+
function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/[^a-zA-Z0-9_-]/g,'\\\\$&')}
|
|
745
|
+
function short(s,max=250){const text=String(s??'');return text.length>max?text.slice(0,max-1)+'...':text}
|
|
746
|
+
async function copyText(text){if(!text)return;try{await navigator.clipboard.writeText(text)}catch{const area=document.createElement('textarea');area.value=text;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();document.execCommand('copy');area.remove()}toast('Thread ID copied')}
|
|
747
|
+
function fmtDate(s){return s?new Date(s).toLocaleString(): '-'}
|
|
748
|
+
function fmtDuration(ms){if(!ms&&ms!==0)return '-';const sec=Math.round(ms/1000);if(sec<60)return sec+'s';return Math.floor(sec/60)+'m '+(sec%60)+'s'}
|
|
749
|
+
function fmtBytes(n){if(n<1024)return n+' B';if(n<1048576)return (n/1024).toFixed(1).replace(/\\.0$/,'')+' KB';return (n/1048576).toFixed(1).replace(/\\.0$/,'')+' MB'}
|
|
750
|
+
function fmtAge(ms){const sec=Math.max(0,Math.floor(ms/1000));if(sec<60)return sec+'s ago';const min=Math.floor(sec/60);if(min<60)return min+'m ago';return Math.floor(min/60)+'h ago'}
|
|
751
|
+
function isCliRunningStatus(msg){return /^Codex CLI running\\b/.test(String(msg||''))}
|
|
752
|
+
function isCliDoneStatus(msg){return /^Codex CLI task\\b/.test(String(msg||''))}
|
|
753
|
+
function applyTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('nordrelayTheme',theme);document.getElementById('themeBtn').textContent=theme==='dark'?'Light':'Dark'}
|
|
754
|
+
function toggleTheme(){applyTheme(document.documentElement.dataset.theme==='dark'?'light':'dark')}
|
|
755
|
+
function page(name){state.currentPage=name;document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.page===name));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+name));document.getElementById('pageTitle').textContent=name[0].toUpperCase()+name.slice(1);document.getElementById('sidebar').classList.remove('open'); if(name==='sessions') loadSessions(); if(name==='settings') loadSettings(); if(name==='logs') loadLogs(); if(name==='diagnostics') loadDiagnostics(); if(name==='artifacts') loadArtifacts(); if(name==='activity') loadActivity();}
|
|
756
|
+
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>page(b.dataset.page));
|
|
757
|
+
document.getElementById('menuBtn').onclick=()=>document.getElementById('sidebar').classList.toggle('open');
|
|
758
|
+
document.getElementById('refreshBtn').onclick=()=>loadBootstrap();
|
|
759
|
+
document.getElementById('themeBtn').onclick=toggleTheme;
|
|
760
|
+
applyTheme(localStorage.getItem('nordrelayTheme') || 'light');
|
|
761
|
+
|
|
762
|
+
function createPaginator(containerId, onChange, pageSize=50){
|
|
763
|
+
const container=document.getElementById(containerId);
|
|
764
|
+
return {
|
|
765
|
+
page:1,
|
|
766
|
+
pageSize,
|
|
767
|
+
reset(){this.page=1},
|
|
768
|
+
render(meta={}){
|
|
769
|
+
const hasPrevious=Boolean(meta.hasPrevious);
|
|
770
|
+
const hasNext=Boolean(meta.hasNext);
|
|
771
|
+
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>';
|
|
772
|
+
const prev=container.querySelector('[data-page-action="prev"]');
|
|
773
|
+
const next=container.querySelector('[data-page-action="next"]');
|
|
774
|
+
prev.onclick=()=>{if(hasPrevious){this.page-=1;onChange()}};
|
|
775
|
+
next.onclick=()=>{if(hasNext){this.page+=1;onChange()}};
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
const sessionsPager=createPaginator('sessionsPager',()=>loadSessions(false),50);
|
|
780
|
+
|
|
781
|
+
async function loadBootstrap(){
|
|
782
|
+
const data = await api('/api/bootstrap');
|
|
783
|
+
state.snapshot = data.status.snapshot;
|
|
784
|
+
state.controls = data.controls;
|
|
785
|
+
state.enabledAgents = data.enabledAgents || [];
|
|
786
|
+
renderSnapshot(state.snapshot);
|
|
787
|
+
renderSessionControls();
|
|
788
|
+
populateNewSessionForm(data.enabledAgents);
|
|
789
|
+
renderAdapters(data.channels, data.agentAdapters);
|
|
790
|
+
document.getElementById('footerVersion').textContent='NordRelay '+(data.status.health?.version || '');
|
|
791
|
+
document.getElementById('footerHealth').textContent='Health: '+(data.status.health?.state?.status || 'unknown');
|
|
792
|
+
const agentSelect=document.getElementById('agentSelect');
|
|
793
|
+
agentSelect.innerHTML=data.enabledAgents.map(a=>'<option value="'+a+'">'+a+'</option>').join('');
|
|
794
|
+
agentSelect.value=state.snapshot.session.agentId;
|
|
795
|
+
agentSelect.onchange=()=>safe(async()=>{await api('/api/agent',{method:'POST',body:JSON.stringify({agentId:agentSelect.value})});toast('Agent switched');await loadBootstrap();await loadChatHistory()});
|
|
796
|
+
}
|
|
797
|
+
function renderSnapshot(s){
|
|
798
|
+
document.getElementById('sessionLine').textContent=(s.session.agentLabel||'Agent')+' / '+(s.session.model||'default')+' / '+(s.session.threadId||'not started');
|
|
799
|
+
document.getElementById('sessionText').textContent=s.sessionText||'';
|
|
800
|
+
document.getElementById('metrics').innerHTML=[
|
|
801
|
+
['Status',s.processing?'working':'idle'],['Agent',s.session.agentLabel],['Queue',s.queue.length],['Workspace',s.session.workspace],['Thread',s.session.threadId||'not started'],['Reasoning',s.session.reasoningEffort||'default'],['Fast',s.session.fastMode?'on':'off']
|
|
802
|
+
].map(([k,v])=>'<div class="metric"><div class="label">'+esc(k)+'</div><div class="value">'+esc(v)+'</div></div>').join('');
|
|
803
|
+
renderQueue(s.queue,s.queuePaused);
|
|
804
|
+
}
|
|
805
|
+
function renderSessionControls(){
|
|
806
|
+
const c=state.controls||{};const s=state.snapshot?.session||{};const caps=c.capabilities||{};
|
|
807
|
+
const modelOptions=['<option value="">Default</option>'].concat((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'" '+(m.slug===s.model?'selected':'')+'>'+esc(m.displayName||m.slug)+'</option>')).join('');
|
|
808
|
+
const reasoningOptions=(c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'" '+(v===s.reasoningEffort?'selected':'')+'>'+esc(v)+'</option>').join('');
|
|
809
|
+
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('');
|
|
810
|
+
document.getElementById('sessionControls').innerHTML=[
|
|
811
|
+
caps.modelSelection?'<label>Model<select id="controlModel">'+modelOptions+'</select></label>':'',
|
|
812
|
+
caps.reasoningSelection?'<label>'+esc(c.reasoningLabel||'Reasoning')+'<select id="controlReasoning">'+reasoningOptions+'</select></label>':'',
|
|
813
|
+
caps.launchProfiles?'<label>Launch<select id="controlLaunch">'+launchOptions+'</select></label>':'',
|
|
814
|
+
caps.fastMode?'<label class="checkbox"><input id="controlFast" type="checkbox" '+(s.fastMode?'checked':'')+'> Fast mode</label>':''
|
|
815
|
+
].join('');
|
|
816
|
+
const model=document.getElementById('controlModel'); if(model) model.onchange=()=>safe(async()=>{if(model.value){await api('/api/session/model',{method:'POST',body:JSON.stringify({model:model.value})});toast('Model updated');loadBootstrap()}});
|
|
817
|
+
const reasoning=document.getElementById('controlReasoning'); if(reasoning) reasoning.onchange=()=>safe(async()=>{await api('/api/session/reasoning',{method:'POST',body:JSON.stringify({reasoning:reasoning.value})});toast((c.reasoningLabel||'Reasoning')+' updated');loadBootstrap()});
|
|
818
|
+
const launch=document.getElementById('controlLaunch'); if(launch) launch.onchange=()=>safe(async()=>{await api('/api/session/launch',{method:'POST',body:JSON.stringify({profileId:launch.value})});toast('Launch profile updated');loadBootstrap()});
|
|
819
|
+
const fast=document.getElementById('controlFast'); if(fast) fast.onchange=()=>safe(async()=>{await api('/api/session/fast',{method:'POST',body:JSON.stringify({enabled:fast.checked})});toast('Fast mode updated');loadBootstrap()});
|
|
820
|
+
}
|
|
821
|
+
function renderAdapters(channels, agents){
|
|
822
|
+
document.getElementById('adapters').innerHTML='<div class="list">'+[...channels.map(c=>'<div class="item"><strong>'+esc(c.label)+' - '+esc(c.status)+'</strong><small>'+esc(c.capabilities.join(', '))+'</small></div>'),...agents.map(a=>'<div class="item"><strong>'+esc(a.label)+' - '+esc(a.status)+'</strong><small>'+esc(a.notes||a.envFlag||'available')+'</small></div>')].join('')+'</div>';
|
|
823
|
+
}
|
|
824
|
+
function appendMessage(cls,text){const box=document.getElementById('messages');const div=document.createElement('div');div.className='message '+cls;div.textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div}
|
|
825
|
+
function renderChatMessages(messages){const box=document.getElementById('messages');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('');box.scrollTop=box.scrollHeight}
|
|
826
|
+
async function loadChatHistory(){const data=await api('/api/chat/history');renderChatMessages(data.messages||[])}
|
|
827
|
+
let currentAgentMessage=null;
|
|
828
|
+
function connectEvents(){
|
|
829
|
+
const qs = token ? '?token='+encodeURIComponent(token) : '';
|
|
830
|
+
const events = new EventSource('/api/events'+qs);
|
|
831
|
+
events.addEventListener('snapshot', e=>{const d=JSON.parse(e.data).data;state.snapshot=d;renderSnapshot(d);renderSessionControls()});
|
|
832
|
+
events.addEventListener('chat_history', e=>renderChatMessages(JSON.parse(e.data).messages||[]));
|
|
833
|
+
events.addEventListener('activity_update', e=>renderActivity(JSON.parse(e.data).events||[]));
|
|
834
|
+
events.addEventListener('session_update', e=>{loadBootstrap();loadChatHistory()});
|
|
835
|
+
events.addEventListener('queue_update', e=>{const d=JSON.parse(e.data);renderQueue(d.queue,d.paused)});
|
|
836
|
+
events.addEventListener('turn_start', e=>{const d=JSON.parse(e.data);appendMessage('user',d.prompt);currentAgentMessage=appendMessage('agent','')});
|
|
837
|
+
events.addEventListener('text_delta', e=>{const d=JSON.parse(e.data);if(!currentAgentMessage)currentAgentMessage=appendMessage('agent','');currentAgentMessage.textContent+=d.delta;currentAgentMessage.scrollIntoView({block:'end'})});
|
|
838
|
+
events.addEventListener('tool_start', e=>{const d=JSON.parse(e.data);tool('tool','Started '+d.toolName)});
|
|
839
|
+
events.addEventListener('tool_update', e=>{const d=JSON.parse(e.data);if(d.partialResult)tool('tool',d.partialResult.slice(-600))});
|
|
840
|
+
events.addEventListener('tool_end', e=>{const d=JSON.parse(e.data);tool(d.isError?'danger':'tool','Finished '+d.toolCallId+(d.isError?' with error':''))});
|
|
841
|
+
events.addEventListener('todo_update', e=>{const d=JSON.parse(e.data);tool('tool','Plan:\\n'+d.items.map(i=>(i.completed?'[x] ':'[ ] ')+i.text).join('\\n'))});
|
|
842
|
+
events.addEventListener('turn_error', e=>{const d=JSON.parse(e.data);appendMessage('system','Error: '+d.error);currentAgentMessage=null});
|
|
843
|
+
events.addEventListener('turn_complete', ()=>{currentAgentMessage=null;loadBootstrap()});
|
|
844
|
+
events.addEventListener('status', e=>{const d=JSON.parse(e.data);const msg=d.message||'';if(isCliRunningStatus(msg)){state.cliStatusActive=true;toast(msg,{sticky:true});return}if(isCliDoneStatus(msg))state.cliStatusActive=false;toast(msg)});
|
|
845
|
+
events.onerror=()=>{};
|
|
846
|
+
}
|
|
847
|
+
function updateToolAgeTitles(){document.querySelectorAll('.tool[data-created-at]').forEach(el=>{const created=Number(el.dataset.createdAt||Date.now());el.title='Updated '+fmtAge(Date.now()-created)})}
|
|
848
|
+
function tool(cls,text){const div=document.createElement('div');div.className='tool '+(cls==='danger'?'danger':'');div.dataset.createdAt=String(Date.now());div.textContent=text;document.getElementById('toolStream').prepend(div);updateToolAgeTitles()}
|
|
849
|
+
setInterval(updateToolAgeTitles,30000);
|
|
850
|
+
let selectedFiles=[];
|
|
851
|
+
function renderSelectedFiles(){const summary=document.getElementById('fileSummary');if(selectedFiles.length===0){summary.textContent='No files selected';return}const names=selectedFiles.slice(0,3).map(f=>f.name || 'file').join(', ');const more=selectedFiles.length>3?' +'+(selectedFiles.length-3)+' more':'';const bytes=selectedFiles.reduce((sum,file)=>sum+file.size,0);summary.textContent=names+more+' ('+fmtBytes(bytes)+')'}
|
|
852
|
+
async function filePayload(file){return {name:file.name || 'upload',mimeType:file.type || 'application/octet-stream',dataBase64:await fileToBase64(file)}}
|
|
853
|
+
async function fileToBase64(file){const buffer=await file.arrayBuffer();const bytes=new Uint8Array(buffer);let binary='';const chunk=0x8000;for(let i=0;i<bytes.length;i+=chunk){binary+=String.fromCharCode(...bytes.subarray(i,i+chunk))}return btoa(binary)}
|
|
854
|
+
document.getElementById('fileInput').onchange=e=>{selectedFiles=Array.from(e.target.files||[]);renderSelectedFiles()};
|
|
855
|
+
document.getElementById('clearFilesBtn').onclick=()=>{selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles()};
|
|
856
|
+
document.getElementById('promptForm').onsubmit=e=>safe(async()=>{e.preventDefault();const input=document.getElementById('promptInput');const text=input.value.trim();if(!text&&selectedFiles.length===0)return;const files=selectedFiles;input.value='';selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles();const payloadFiles=files.length?await Promise.all(files.map(filePayload)):[];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})});if(r.transcribeOnly)appendMessage('system','Transcribed audio:\\n'+(r.transcript||'(empty)'));else if(r.queued)appendMessage('system','Queued prompt '+r.queueId)},e);
|
|
857
|
+
document.getElementById('newSessionBtn').onclick=()=>openNewSessionDialog();
|
|
858
|
+
document.getElementById('clearChatBtn').onclick=()=>safe(async()=>{if(confirm('Clear chat history for the current thread?')){const r=await api('/api/chat/history',{method:'DELETE'});renderChatMessages(r.messages||[]);toast('Removed '+r.removed+' messages')}});
|
|
859
|
+
document.getElementById('abortBtn').onclick=()=>safe(async()=>{await api('/api/abort',{method:'POST'});toast('Abort sent')});
|
|
860
|
+
document.getElementById('handbackBtn').onclick=()=>safe(async()=>{const r=await api('/api/handback',{method:'POST'});appendMessage('system','Handback command:\\n'+(r.command||'No command available'))});
|
|
861
|
+
function populateNewSessionForm(agents){const c=state.controls||{};const s=state.snapshot?.session||{};document.getElementById('newAgent').innerHTML=(agents||[]).map(a=>'<option value="'+attr(a)+'" '+(a===s.agentId?'selected':'')+'>'+esc(a)+'</option>').join('');document.getElementById('newWorkspace').value=s.workspace||'';document.getElementById('workspaceOptions').innerHTML=(c.workspaces||[]).map(w=>'<option value="'+attr(w)+'"></option>').join('');document.getElementById('newModel').innerHTML='<option value="">Default</option>'+((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'">'+esc(m.displayName||m.slug)+'</option>').join(''));document.getElementById('newReasoning').innerHTML='<option value="">Default</option>'+((c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'">'+esc(v)+'</option>').join(''));document.getElementById('newLaunch').innerHTML='<option value="">Default</option>'+((c.launchProfiles||[]).map(p=>'<option value="'+attr(p.id)+'">'+esc(p.label+' - '+p.behavior)+'</option>').join(''));document.getElementById('newFast').checked=Boolean(s.fastMode);document.getElementById('newLaunchWrap').style.display=(c.capabilities&&c.capabilities.launchProfiles)?'grid':'none';document.getElementById('newFastWrap').style.display=(c.capabilities&&c.capabilities.fastMode)?'inline-flex':'none'}
|
|
862
|
+
function openNewSessionDialog(){populateNewSessionForm(state.enabledAgents);document.getElementById('newSessionDialog').showModal()}
|
|
863
|
+
document.getElementById('newSessionForm').onsubmit=e=>safe(async()=>{e.preventDefault();const payload={agentId:val('newAgent'),workspace:val('newWorkspace')||undefined,model:val('newModel')||undefined,reasoningEffort:val('newReasoning')||undefined,launchProfileId:val('newLaunch')||undefined,fastMode:document.getElementById('newFast').checked};await api('/api/sessions/new',{method:'POST',body:JSON.stringify(payload)});document.getElementById('newSessionDialog').close();toast('New session started');await loadBootstrap();await loadChatHistory()},e);
|
|
864
|
+
document.getElementById('cancelSessionBtn').onclick=()=>document.getElementById('newSessionDialog').close();
|
|
865
|
+
function val(id){return document.getElementById(id).value.trim()}
|
|
866
|
+
async function loadSessions(reset=true){if(reset)sessionsPager.reset();const q=document.getElementById('sessionSearch').value||'';const data=await api('/api/sessions?query='+encodeURIComponent(q)+'&page='+sessionsPager.page+'&limit='+sessionsPager.pageSize);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)+'">Switch</button></div></div>').join('')||'<div class="item">No sessions found.</div>';sessionsPager.render(data.pagination||{});document.querySelectorAll('[data-copy-id]').forEach(b=>b.onclick=()=>copyText(b.dataset.copyId||''));document.querySelectorAll('[data-switch]').forEach(b=>b.onclick=async()=>{await api('/api/sessions/switch',{method:'POST',body:JSON.stringify({threadId:b.dataset.switch})});toast('Session switched');loadBootstrap()})}
|
|
867
|
+
document.getElementById('sessionSearchBtn').onclick=()=>loadSessions(true);document.getElementById('sessionSearch').addEventListener('keydown',e=>{if(e.key==='Enter')loadSessions(true)});document.getElementById('attachBtn').onclick=async()=>{const threadId=document.getElementById('attachInput').value.trim();if(threadId){await api('/api/sessions/attach',{method:'POST',body:JSON.stringify({threadId})});toast('Session attached');loadBootstrap()}};
|
|
868
|
+
function renderQueue(queue,paused){document.getElementById('queueStatus').textContent=paused?'Paused':'Running';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+'">Run</button><button data-q="top" data-id="'+q.id+'">Top</button><button data-q="up" data-id="'+q.id+'">Up</button><button data-q="down" data-id="'+q.id+'">Down</button><button data-q="cancel" data-id="'+q.id+'" class="danger">Cancel</button></div></div>').join('')||'<div class="item">Queue is empty.</div>';document.querySelectorAll('[data-q]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})});renderQueue(r.queue,r.paused)}));let dragged=null;document.querySelectorAll('.queue-item').forEach(item=>{item.ondragstart=()=>{dragged=item.dataset.queueId;item.classList.add('dragging')};item.ondragend=()=>item.classList.remove('dragging');item.ondragover=e=>e.preventDefault();item.ondrop=()=>safe(async()=>{if(dragged&&dragged!==item.dataset.queueId){const ids=Array.from(document.querySelectorAll('.queue-item')).map(el=>el.dataset.queueId);const targetIndex=Math.max(0,ids.indexOf(item.dataset.queueId));await api('/api/queue',{method:'POST',body:JSON.stringify({action:'top',id:dragged})});for(let i=0;i<targetIndex;i++)await api('/api/queue',{method:'POST',body:JSON.stringify({action:'down',id:dragged})});const r=await api('/api/queue');renderQueue(r.queue,r.paused)}})})}
|
|
869
|
+
document.querySelectorAll('[data-queue]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.queue})});renderQueue(r.queue,r.paused)}));
|
|
870
|
+
async function loadArtifacts(){const data=await api('/api/artifacts');document.getElementById('artifactList').innerHTML=data.reports.map(r=>'<div class="item"><strong>'+esc(r.turnId)+' - '+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)+(token?'&token='+encodeURIComponent(token):'')+'">Download ZIP</a><button data-del-art="'+esc(r.turnId)+'" class="danger">Delete</button></div>'+r.artifacts.slice(0,12).map(a=>'<small><a href="/api/artifacts/file?turnId='+encodeURIComponent(r.turnId)+'&path='+encodeURIComponent(a.relativePath)+(token?'&token='+encodeURIComponent(token):'')+'">'+esc(a.name)+'</a> '+fmtBytes(a.sizeBytes)+' <button class="secondary" data-preview-turn="'+attr(r.turnId)+'" data-preview-path="'+attr(a.relativePath)+'">Preview</button></small>').join('')+'</div>').join('')||'<div class="item">No artifacts.</div>';document.querySelectorAll('[data-del-art]').forEach(b=>b.onclick=()=>safe(async()=>{if(confirm('Delete artifact turn '+b.dataset.delArt+'?')){await api('/api/artifacts?turnId='+encodeURIComponent(b.dataset.delArt),{method:'DELETE'});loadArtifacts()}}));document.querySelectorAll('[data-preview-turn]').forEach(b=>b.onclick=()=>previewArtifact(b.dataset.previewTurn,b.dataset.previewPath))}
|
|
871
|
+
document.getElementById('reloadArtifactsBtn').onclick=loadArtifacts;
|
|
872
|
+
async function previewArtifact(turnId,path){const data=await api('/api/artifacts/preview?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path));const target=document.getElementById('artifactPreview');if(data.kind==='image'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><img src="/api/artifacts/file?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path)+(token?'&token='+encodeURIComponent(token):'')+'"></div>';return}if(data.kind==='text'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+' '+fmtBytes(data.sizeBytes)+'</h2><pre>'+esc(data.text||'')+'</pre>'+(data.truncated?'<small>Preview truncated.</small>':'')+'</div>';return}target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><p>'+esc(data.detail||'Preview unavailable')+'</p></div>'}
|
|
873
|
+
async function loadActivity(){const q='?source='+encodeURIComponent(val('activitySource'))+'&status='+encodeURIComponent(val('activityStatus'))+'&limit='+encodeURIComponent(val('activityLimit')||'100');const data=await api('/api/activity'+q);renderActivity(data.events||[])}
|
|
874
|
+
function renderActivity(events){document.getElementById('activityList').innerHTML=(events||[]).map(e=>'<div class="item"><strong>'+esc(fmtDate(e.timestamp)+' / '+e.source+' / '+e.status+' / '+e.type)+'</strong><small>'+esc(short(e.prompt||e.detail||'',220))+'</small><small>'+esc((e.threadId||'-')+' / '+(e.workspace||'-')+' / '+fmtDuration(e.durationMs))+'</small></div>').join('')||'<div class="item">No activity.</div>'}
|
|
875
|
+
document.getElementById('loadActivityBtn').onclick=()=>loadActivity();
|
|
876
|
+
async function loadSettings(){const data=await api('/api/settings');state.settings=data.settings;renderSettings()}
|
|
877
|
+
function renderSettings(){const groups={};state.settings.forEach(s=>(groups[s.group]??=[]).push(s));const names=Object.keys(groups);if(!state.settingsGroup||!groups[state.settingsGroup])state.settingsGroup=names[0];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('');document.querySelectorAll('[data-setting-tab]').forEach(b=>b.onclick=()=>{state.settingsGroup=b.dataset.settingTab;renderSettings()});const items=groups[state.settingsGroup]||[];document.getElementById('settingsForm').innerHTML='<div class="settings-section"><h2>'+esc(state.settingsGroup||'Settings')+'</h2>'+items.map(s=>'<div class="setting" data-setting-box="'+attr(s.key)+'"><label>'+esc(s.label)+'</label>'+settingInput(s)+'<small>'+esc(s.key)+' - '+esc(s.description)+(s.restartRequired?' Restart required.':'')+(s.configured?' Configured.':' Inherited/default.')+'</small><div class="setting-error"></div></div>').join('')+'</div>'}
|
|
878
|
+
function settingInput(s){const value=esc(s.value||''); if(s.options)return '<select data-setting="'+s.key+'"><option value=""></option>'+s.options.map(o=>'<option value="'+attr(o)+'" '+(s.value===o?'selected':'')+'>'+esc(o)+'</option>').join('')+'</select>'; if(s.kind==='boolean')return '<select data-setting="'+s.key+'"><option value=""></option><option value="true" '+(s.value==='true'?'selected':'')+'>true</option><option value="false" '+(s.value==='false'?'selected':'')+'>false</option></select>'; if(s.kind==='json')return '<textarea rows="4" data-setting="'+s.key+'">'+value+'</textarea>'; return '<input data-setting="'+s.key+'" value="'+value+'" '+(s.kind==='secret'?'type="password"':'')+'>'}
|
|
879
|
+
document.getElementById('saveSettingsBtn').onclick=()=>safe(async()=>{document.querySelectorAll('.setting-error').forEach(e=>e.textContent='');const patch={};document.querySelectorAll('[data-setting]').forEach(el=>patch[el.dataset.setting]=el.value);const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings:patch})});(r.errors||[]).forEach(err=>{const box=document.querySelector('[data-setting-box="'+cssEscape(err.key)+'"] .setting-error');if(box)box.textContent=err.message});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');toast((r.errors&&r.errors.length)?'Settings need attention':'Settings saved')});
|
|
880
|
+
document.getElementById('restartBtn').onclick=()=>safe(async()=>{if(confirm('Restart NordRelay now?')){await api('/api/runtime/restart',{method:'POST'});toast('Restart requested')}});
|
|
881
|
+
async function loadLogs(){const target=document.getElementById('logTarget').value;const lines=document.getElementById('logLines').value;const data=await api('/api/logs?target='+target+'&lines='+lines);state.logsPlain=data.plain||'';renderLogs()}document.getElementById('loadLogsBtn').onclick=loadLogs;
|
|
882
|
+
function renderLogs(){const level=val('logLevel');const query=val('logSearch').toLowerCase();const lines=state.logsPlain.split(/\\n/).filter(line=>(level==='all'||line.includes(level))&&(!query||line.toLowerCase().includes(query)));document.getElementById('logs').textContent=lines.join('\\n')||'(empty)'}
|
|
883
|
+
document.getElementById('logLevel').onchange=renderLogs;document.getElementById('logSearch').oninput=renderLogs;document.getElementById('logAutoRefresh').onchange=e=>{clearInterval(state.logTimer);state.logTimer=null;if(e.target.checked)state.logTimer=setInterval(loadLogs,5000)};document.getElementById('downloadLogsBtn').onclick=()=>{const blob=new Blob([document.getElementById('logs').textContent||''],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-log.txt';a.click();URL.revokeObjectURL(a.href)};
|
|
884
|
+
async function loadDiagnostics(){const data=await api('/api/diagnostics');document.getElementById('diagnostics').innerHTML=diagnosticsHtml(data)}
|
|
885
|
+
function diagnosticsHtml(d){const h=d.health||{};const s=d.snapshot?.session||{};const vc=d.versionChecks||{};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',s.fastMode?'on':'off']])+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>'}
|
|
886
|
+
function card(title,rows){return '<div class="item"><strong>'+esc(title)+'</strong>'+rows.map(r=>'<small>'+esc(r[0])+': '+esc(r[1]??'-')+'</small>').join('')+'</div>'}
|
|
887
|
+
function safe(fn,event){if(event&&event.preventDefault)event.preventDefault();Promise.resolve().then(fn).catch(err=>toast(err.message||String(err)))}
|
|
888
|
+
loadBootstrap().then(()=>{connectEvents();loadChatHistory();loadSessions();loadArtifacts();loadSettings();loadLogs();loadDiagnostics();loadActivity()}).catch(err=>toast(err.message));
|
|
889
|
+
`;
|
|
890
|
+
}
|