relmio 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +54 -436
- package/docs/brand.md +5 -4
- package/docs/faq.md +45 -0
- package/docs/getting-started.md +38 -0
- package/docs/images/brand/relmio-mark.svg +2 -8
- package/docs/local-endpoints-spec.md +27 -0
- package/docs/local-endpoints.md +72 -11
- package/docs/reference.md +94 -0
- package/docs/security.md +34 -0
- package/docs/troubleshooting.md +33 -0
- package/docs/vps-and-n8n.md +30 -0
- package/package.json +1 -1
- package/src/gateway/codex-chat.js +103 -2
- package/src/services/local-chat-test.js +501 -0
- package/src/ui/local.css +196 -1
- package/src/ui/local.html +113 -5
- package/src/ui/local.js +395 -1
- package/src/web/server.js +203 -1
package/src/web/server.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
prepareLocalClientCredentialRotation,
|
|
35
35
|
} from "../services/local-installer.js";
|
|
36
36
|
import { startCodexDeviceLogin } from "../services/codex-login.js";
|
|
37
|
+
import { createLocalChatTestService } from "../services/local-chat-test.js";
|
|
37
38
|
|
|
38
39
|
const MAX_BODY_BYTES = 32 * 1024;
|
|
39
40
|
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
@@ -87,6 +88,7 @@ const defaultServices = {
|
|
|
87
88
|
resolveLocalInstallRoot,
|
|
88
89
|
restartLocalCodex,
|
|
89
90
|
startCodexDeviceLogin,
|
|
91
|
+
createLocalChatTestService,
|
|
90
92
|
};
|
|
91
93
|
|
|
92
94
|
function setSecurityHeaders(response) {
|
|
@@ -113,6 +115,64 @@ function sendJson(response, statusCode, body) {
|
|
|
113
115
|
response.end(contents);
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
function requestAcceptsEventStream(request) {
|
|
119
|
+
const value = request.headers.accept;
|
|
120
|
+
return (
|
|
121
|
+
typeof value === "string" &&
|
|
122
|
+
value
|
|
123
|
+
.split(",")
|
|
124
|
+
.some((entry) => entry.trim().split(";", 1)[0] === "text/event-stream")
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function startLocalChatTestStream(response) {
|
|
129
|
+
let ended = false;
|
|
130
|
+
response.writeHead(200, {
|
|
131
|
+
"Content-Encoding": "none",
|
|
132
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
133
|
+
"X-Accel-Buffering": "no",
|
|
134
|
+
"X-Relmio-Stream": "v1",
|
|
135
|
+
});
|
|
136
|
+
const send = (event, data) => {
|
|
137
|
+
if (ended || response.writableEnded || response.destroyed) return;
|
|
138
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
139
|
+
};
|
|
140
|
+
const keepalive = setInterval(() => {
|
|
141
|
+
if (!ended && !response.writableEnded && !response.destroyed) {
|
|
142
|
+
response.write(": keepalive\n\n");
|
|
143
|
+
}
|
|
144
|
+
}, 15_000);
|
|
145
|
+
keepalive.unref?.();
|
|
146
|
+
send("start", { requestId: randomUUID() });
|
|
147
|
+
return {
|
|
148
|
+
send(event, data) {
|
|
149
|
+
if (event === "progress" || event === "delta") send(event, data);
|
|
150
|
+
},
|
|
151
|
+
complete(result) {
|
|
152
|
+
if (ended) return;
|
|
153
|
+
send("terminal", {
|
|
154
|
+
outcome: "completed",
|
|
155
|
+
conversationId: result.conversationId,
|
|
156
|
+
});
|
|
157
|
+
ended = true;
|
|
158
|
+
clearInterval(keepalive);
|
|
159
|
+
response.end();
|
|
160
|
+
},
|
|
161
|
+
fail(code = "upstream_failed") {
|
|
162
|
+
if (ended) return;
|
|
163
|
+
send("error", { code, retryable: true });
|
|
164
|
+
send("terminal", { outcome: "failed" });
|
|
165
|
+
ended = true;
|
|
166
|
+
clearInterval(keepalive);
|
|
167
|
+
response.end();
|
|
168
|
+
},
|
|
169
|
+
dispose() {
|
|
170
|
+
ended = true;
|
|
171
|
+
clearInterval(keepalive);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
116
176
|
function tokenMatches(actual, expected) {
|
|
117
177
|
if (typeof actual !== "string") {
|
|
118
178
|
return false;
|
|
@@ -373,6 +433,67 @@ function createSafeProjectMeta(result) {
|
|
|
373
433
|
};
|
|
374
434
|
}
|
|
375
435
|
|
|
436
|
+
function createSafeLocalChatTestKey(result) {
|
|
437
|
+
if (
|
|
438
|
+
!result ||
|
|
439
|
+
typeof result !== "object" ||
|
|
440
|
+
Array.isArray(result) ||
|
|
441
|
+
typeof result.keyId !== "string" ||
|
|
442
|
+
result.keyId.length === 0 ||
|
|
443
|
+
result.keyId.length > 128 ||
|
|
444
|
+
!result.publicKeyJwk ||
|
|
445
|
+
typeof result.publicKeyJwk !== "object" ||
|
|
446
|
+
Array.isArray(result.publicKeyJwk) ||
|
|
447
|
+
result.publicKeyJwk.kty !== "RSA" ||
|
|
448
|
+
typeof result.publicKeyJwk.n !== "string" ||
|
|
449
|
+
typeof result.publicKeyJwk.e !== "string" ||
|
|
450
|
+
result.publicKeyJwk.n.length === 0 ||
|
|
451
|
+
result.publicKeyJwk.n.length > 1_024 ||
|
|
452
|
+
result.publicKeyJwk.e.length === 0 ||
|
|
453
|
+
result.publicKeyJwk.e.length > 32 ||
|
|
454
|
+
result.algorithm !== "RSA-OAEP-256" ||
|
|
455
|
+
typeof result.expiresAt !== "string" ||
|
|
456
|
+
Number.isNaN(Date.parse(result.expiresAt))
|
|
457
|
+
) {
|
|
458
|
+
throw Object.assign(new Error("The local tester could not start safely."), {
|
|
459
|
+
statusCode: 502,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
keyId: result.keyId,
|
|
464
|
+
publicKeyJwk: {
|
|
465
|
+
kty: "RSA",
|
|
466
|
+
n: result.publicKeyJwk.n,
|
|
467
|
+
e: result.publicKeyJwk.e,
|
|
468
|
+
},
|
|
469
|
+
algorithm: result.algorithm,
|
|
470
|
+
expiresAt: result.expiresAt,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function createSafeLocalChatTestResponse(result) {
|
|
475
|
+
if (
|
|
476
|
+
!result ||
|
|
477
|
+
typeof result !== "object" ||
|
|
478
|
+
Array.isArray(result) ||
|
|
479
|
+
typeof result.conversationId !== "string" ||
|
|
480
|
+
result.conversationId.length === 0 ||
|
|
481
|
+
result.conversationId.length > 160 ||
|
|
482
|
+
typeof result.output !== "string" ||
|
|
483
|
+
result.output.length === 0 ||
|
|
484
|
+
result.output.length > 12 * 1_024
|
|
485
|
+
) {
|
|
486
|
+
throw Object.assign(
|
|
487
|
+
new Error("The local adapter returned an unexpected response."),
|
|
488
|
+
{ statusCode: 502 },
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
conversationId: result.conversationId,
|
|
493
|
+
output: result.output,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
376
497
|
function getPendingLocalCredentialRotation(state) {
|
|
377
498
|
if (
|
|
378
499
|
state.localCredentialRotationPending &&
|
|
@@ -409,6 +530,15 @@ function requireLiveLocalAction(state, action) {
|
|
|
409
530
|
}
|
|
410
531
|
}
|
|
411
532
|
|
|
533
|
+
function requireReadyLocalChatTester(state) {
|
|
534
|
+
if (state.localInstalledTarget !== "codex-chat") {
|
|
535
|
+
throw Object.assign(
|
|
536
|
+
new Error("Install the Codex Chat Adapter before starting its local tester."),
|
|
537
|
+
{ statusCode: 409 },
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
412
542
|
async function handleApi(request, response, path, state) {
|
|
413
543
|
requireApiToken(request, state);
|
|
414
544
|
requireSameOrigin(request, state);
|
|
@@ -603,6 +733,68 @@ async function handleApi(request, response, path, state) {
|
|
|
603
733
|
|
|
604
734
|
const body = await readJsonBody(request);
|
|
605
735
|
|
|
736
|
+
if (path === "/api/local/chat-test/key") {
|
|
737
|
+
requireLiveLocalAction(state, "Local chat testing");
|
|
738
|
+
requireReadyLocalChatTester(state);
|
|
739
|
+
enforceRateLimit(state, path);
|
|
740
|
+
sendJson(
|
|
741
|
+
response,
|
|
742
|
+
200,
|
|
743
|
+
createSafeLocalChatTestKey(await state.localChatTest.issueKey()),
|
|
744
|
+
);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (path === "/api/local/chat-test/message") {
|
|
749
|
+
requireLiveLocalAction(state, "Local chat testing");
|
|
750
|
+
requireReadyLocalChatTester(state);
|
|
751
|
+
enforceRateLimit(state, path);
|
|
752
|
+
const wantsStream = requestAcceptsEventStream(request);
|
|
753
|
+
const stream = wantsStream ? startLocalChatTestStream(response) : null;
|
|
754
|
+
const requestController = wantsStream ? new AbortController() : null;
|
|
755
|
+
const abortOnClose = () => {
|
|
756
|
+
if (!response.writableEnded) requestController?.abort();
|
|
757
|
+
};
|
|
758
|
+
response.once("close", abortOnClose);
|
|
759
|
+
try {
|
|
760
|
+
const result = createSafeLocalChatTestResponse(
|
|
761
|
+
await state.localChatTest.message(body, {
|
|
762
|
+
...(stream
|
|
763
|
+
? {
|
|
764
|
+
onEvent: (event, data) => stream.send(event, data),
|
|
765
|
+
signal: requestController.signal,
|
|
766
|
+
}
|
|
767
|
+
: {}),
|
|
768
|
+
}),
|
|
769
|
+
);
|
|
770
|
+
if (stream) stream.complete(result);
|
|
771
|
+
else sendJson(response, 200, result);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
if (stream) {
|
|
774
|
+
stream.fail(error?.statusCode === 504 ? "timeout" : "upstream_failed");
|
|
775
|
+
} else {
|
|
776
|
+
throw error;
|
|
777
|
+
}
|
|
778
|
+
} finally {
|
|
779
|
+
response.off("close", abortOnClose);
|
|
780
|
+
stream?.dispose();
|
|
781
|
+
if (body && typeof body === "object") {
|
|
782
|
+
body.encryptedCredential = undefined;
|
|
783
|
+
body.input = undefined;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (path === "/api/local/chat-test/reset") {
|
|
790
|
+
requireLiveLocalAction(state, "Local chat testing");
|
|
791
|
+
requireReadyLocalChatTester(state);
|
|
792
|
+
enforceRateLimit(state, path);
|
|
793
|
+
await state.localChatTest.reset(body);
|
|
794
|
+
sendJson(response, 200, { forgotten: true });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
606
798
|
if (path === "/api/oauth/cancel") {
|
|
607
799
|
requireLiveLocalAction(state, "Live ChatGPT sign-in");
|
|
608
800
|
const login = state.oauthLogin;
|
|
@@ -670,12 +862,14 @@ async function handleApi(request, response, path, state) {
|
|
|
670
862
|
state.localInstallInFlight = true;
|
|
671
863
|
acquiredInstallLock = true;
|
|
672
864
|
state.localPlan = null;
|
|
865
|
+
state.localInstalledTarget = null;
|
|
673
866
|
const result = await state.services.installLocalEndpoint({
|
|
674
867
|
plan: pending.plan,
|
|
675
868
|
apiKey: body.apiKey,
|
|
676
869
|
confirmed: body.confirmed,
|
|
677
870
|
});
|
|
678
871
|
|
|
872
|
+
state.localInstalledTarget = pending.plan.target;
|
|
679
873
|
sendJson(response, 200, createSafeLocalInstallResult(result));
|
|
680
874
|
} finally {
|
|
681
875
|
if (acquiredInstallLock) {
|
|
@@ -707,6 +901,7 @@ async function handleApi(request, response, path, state) {
|
|
|
707
901
|
|
|
708
902
|
state.localCredentialRotationInFlight = true;
|
|
709
903
|
try {
|
|
904
|
+
state.localChatTest.resetAll?.();
|
|
710
905
|
const result = await state.services.prepareLocalClientCredentialRotation({
|
|
711
906
|
target: validateLocalTarget(body.target),
|
|
712
907
|
});
|
|
@@ -749,6 +944,7 @@ async function handleApi(request, response, path, state) {
|
|
|
749
944
|
state.localCredentialRotationPending = null;
|
|
750
945
|
state.localCredentialRotationInFlight = true;
|
|
751
946
|
try {
|
|
947
|
+
state.localChatTest.resetAll?.();
|
|
752
948
|
const result = await state.services.activateLocalClientCredentialRotation({
|
|
753
949
|
target: pending.target,
|
|
754
950
|
clientCredential: body.clientCredential,
|
|
@@ -1079,9 +1275,13 @@ export async function startWizardServer({
|
|
|
1079
1275
|
throw new TypeError("A strong wizard session token is required.");
|
|
1080
1276
|
}
|
|
1081
1277
|
|
|
1278
|
+
const resolvedServices = { ...defaultServices, ...services };
|
|
1082
1279
|
const state = {
|
|
1083
1280
|
sessionToken,
|
|
1084
|
-
services:
|
|
1281
|
+
services: resolvedServices,
|
|
1282
|
+
localChatTest:
|
|
1283
|
+
resolvedServices.localChatTest ??
|
|
1284
|
+
resolvedServices.createLocalChatTestService(),
|
|
1085
1285
|
uiFiles: uiFiles ?? (await loadDefaultUiFiles()),
|
|
1086
1286
|
origin: "http://127.0.0.1",
|
|
1087
1287
|
connection: null,
|
|
@@ -1094,6 +1294,7 @@ export async function startWizardServer({
|
|
|
1094
1294
|
oauthLoginStartInFlight: false,
|
|
1095
1295
|
oauthLoginStartPromise: null,
|
|
1096
1296
|
localPlan: null,
|
|
1297
|
+
localInstalledTarget: null,
|
|
1097
1298
|
localInstallInFlight: false,
|
|
1098
1299
|
localCredentialRotationInFlight: false,
|
|
1099
1300
|
localCredentialRotationPending: null,
|
|
@@ -1122,6 +1323,7 @@ export async function startWizardServer({
|
|
|
1122
1323
|
origin: state.origin,
|
|
1123
1324
|
async close() {
|
|
1124
1325
|
state.closing = true;
|
|
1326
|
+
state.localChatTest.dispose?.();
|
|
1125
1327
|
await waitForBoundedResult(
|
|
1126
1328
|
state.oauthLoginStartPromise,
|
|
1127
1329
|
state.oauthShutdownWaitMs,
|