relmio 0.7.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 +25 -0
- package/README.md +6 -1
- package/docs/brand.md +5 -4
- package/docs/images/brand/relmio-mark.svg +2 -8
- package/docs/local-endpoints.md +22 -2
- package/docs/reference.md +17 -0
- package/package.json +1 -1
- package/src/gateway/codex-chat.js +103 -2
- package/src/services/local-chat-test.js +144 -4
- package/src/ui/local.css +83 -2
- package/src/ui/local.html +15 -6
- package/src/ui/local.js +146 -14
- package/src/web/server.js +84 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ checks the registry separately after publication.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Let trusted local backends and development servers receive Chat Adapter turns
|
|
15
|
+
as opt-in Server-Sent Events, with progress and text deltas followed by one
|
|
16
|
+
explicit terminal outcome so a completed response is distinguishable from a
|
|
17
|
+
redacted failure.
|
|
18
|
+
- Let the setup-token-protected local wizard tester show that incremental
|
|
19
|
+
response flow after a short-lived encrypted credential handoff, without a
|
|
20
|
+
direct browser-to-adapter request.
|
|
21
|
+
- Refresh the hosted chat experience and local installer presentation, and
|
|
22
|
+
adopt the Harbor Gate abstract mark across Relmio surfaces.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Document the streaming contract, local tester behavior, and its limits in the
|
|
27
|
+
canonical endpoint/reference guides and generated hosted documentation.
|
|
28
|
+
|
|
29
|
+
### Security
|
|
30
|
+
|
|
31
|
+
- Keep the Chat Adapter experimental, loopback-only, and limited to trusted
|
|
32
|
+
local backends or development servers; it rejects browser origins and is not
|
|
33
|
+
an OpenAI `/v1` endpoint or a substitute for an OpenAI Platform API key.
|
|
34
|
+
|
|
10
35
|
## [0.7.0] - 2026-08-16
|
|
11
36
|
|
|
12
37
|
### Added
|
package/README.md
CHANGED
|
@@ -18,13 +18,18 @@ final confirmation before it writes files or deploys a VPS sidecar.
|
|
|
18
18
|
| --- | --- | --- |
|
|
19
19
|
| Local OpenAI gateway | OpenAI-compatible `/v1` | Your OpenAI Platform API key |
|
|
20
20
|
| Codex App Server | Experimental JSON-RPC/WebSocket | ChatGPT sign-in and a high-trust local capability |
|
|
21
|
-
| Codex Chat Adapter | Experimental `POST /chat` | ChatGPT sign-in and a bearer for trusted local backends |
|
|
21
|
+
| Codex Chat Adapter | Experimental `POST /chat` (JSON or opt-in SSE) | ChatGPT sign-in and a bearer for trusted local backends |
|
|
22
22
|
| n8n sidecar | Private Docker-network `/v1` bridge | A local ChatGPT sign-in file, never a host port |
|
|
23
23
|
|
|
24
24
|
ChatGPT sign-in does not become an OpenAI Platform API key. Codex transports
|
|
25
25
|
are not generic `/v1` services and should never be exposed on a LAN or public
|
|
26
26
|
network.
|
|
27
27
|
|
|
28
|
+
The experimental Chat Adapter's SSE stream succeeds only after its
|
|
29
|
+
`terminal: completed` event. Its local tester stays behind the setup-token-
|
|
30
|
+
protected wizard and uses a short-lived encrypted credential handoff, never a
|
|
31
|
+
direct browser-to-adapter request.
|
|
32
|
+
|
|
28
33
|
## ChatGPT sign-in lifetime
|
|
29
34
|
|
|
30
35
|
ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
|
package/docs/brand.md
CHANGED
|
@@ -12,11 +12,12 @@ applications.
|
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
The **Harbor Gate** mark uses two distinct boundaries around an open passage.
|
|
16
|
+
Its teal entry stands upright while the ink route bends outward, representing
|
|
17
|
+
a local credential boundary and a controlled path for requests and responses.
|
|
18
|
+
The silhouette stays recognizable at favicon size without provider branding.
|
|
18
19
|
|
|
19
|
-
The mark was developed from the checked-in generated concept source at
|
|
20
|
+
The abstract mark was developed from the checked-in generated concept source at
|
|
20
21
|
[`images/brand/relmio-concept-source.png`](images/brand/relmio-concept-source.png)
|
|
21
22
|
and redrawn as compact SVG geometry for reliable rendering at small sizes.
|
|
22
23
|
|
|
@@ -5,12 +5,6 @@
|
|
|
5
5
|
aria-labelledby="relmio-mark-title"
|
|
6
6
|
>
|
|
7
7
|
<title id="relmio-mark-title">Relmio mark</title>
|
|
8
|
-
<path
|
|
9
|
-
|
|
10
|
-
d="M7 8h19l31 24H41L26 20v10L7 17Z"
|
|
11
|
-
/>
|
|
12
|
-
<path
|
|
13
|
-
fill="#12211f"
|
|
14
|
-
d="M7 47l19-13v10l15-12h16L26 56H7Z"
|
|
15
|
-
/>
|
|
8
|
+
<path fill="#137c74" d="M8 13 28 5v38c0 4-2 7-6 9L8 59Z" />
|
|
9
|
+
<path fill="#12211f" d="M36 5l11 5c6 3 9 8 9 15v9c0 3 1 5 3 7l3 3c3 3 3 8 0 11s-8 3-11 0L40 44c-3-3-4-6-4-10Z" />
|
|
16
10
|
</svg>
|
package/docs/local-endpoints.md
CHANGED
|
@@ -236,7 +236,7 @@ Protocol: Relmio Codex Chat HTTP
|
|
|
236
236
|
```
|
|
237
237
|
|
|
238
238
|
After completing the same official Codex device-code sign-in, a local backend
|
|
239
|
-
can start a conversation
|
|
239
|
+
can start a conversation. Read the bearer rather than placing it in a
|
|
240
240
|
shell command:
|
|
241
241
|
|
|
242
242
|
```bash
|
|
@@ -261,6 +261,24 @@ text:
|
|
|
261
261
|
}
|
|
262
262
|
```
|
|
263
263
|
|
|
264
|
+
To verify incremental delivery, request Relmio's versioned event stream. The
|
|
265
|
+
stream emits `start`, `progress`, zero or more `delta` events, and exactly one
|
|
266
|
+
`terminal` event. A completed terminal includes the `conversationId`; a failed
|
|
267
|
+
terminal is preceded by a redacted `error` event:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
read -r -s RELMIO_CODEX_CHAT_KEY
|
|
271
|
+
printf '\n'
|
|
272
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CODEX_CHAT_KEY" |
|
|
273
|
+
curl --no-buffer --fail-with-body --silent --show-error \
|
|
274
|
+
--request POST http://127.0.0.1:14501/chat \
|
|
275
|
+
--header @- \
|
|
276
|
+
--header "Accept: text/event-stream" \
|
|
277
|
+
--header "Content-Type: application/json" \
|
|
278
|
+
--data '{"input":"What is a robot? Answer in two short sentences."}'
|
|
279
|
+
unset RELMIO_CODEX_CHAT_KEY
|
|
280
|
+
```
|
|
281
|
+
|
|
264
282
|
Send that `conversationId` with the next `input` to continue the same
|
|
265
283
|
conversation. The adapter initializes the official App Server, starts or
|
|
266
284
|
resumes the thread, runs a read-only conversational turn, and returns the
|
|
@@ -301,7 +319,9 @@ This reduces accidental credential transit and storage exposure. It is not
|
|
|
301
319
|
encryption at rest or end-to-end encryption, and it cannot protect against a
|
|
302
320
|
compromised browser, extension, or local machine. The tester rejects redirects,
|
|
303
321
|
non-loopback URLs, malformed or oversized data, concurrent key use, and
|
|
304
|
-
adapter failures with redacted messages.
|
|
322
|
+
adapter failures with redacted messages. Assistant text appears incrementally
|
|
323
|
+
while the adapter is working; the tester reports success only after the
|
|
324
|
+
completed terminal event arrives.
|
|
305
325
|
|
|
306
326
|
## Network and container boundary
|
|
307
327
|
|
package/docs/reference.md
CHANGED
|
@@ -26,6 +26,23 @@ printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
|
|
|
26
26
|
--data '{"input":"Reply with exactly: adapter works"}'
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
Stream a conversation and inspect its explicit terminal state:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
|
|
33
|
+
curl --no-buffer --fail-with-body --silent --show-error \
|
|
34
|
+
--request POST "$RELMIO_CHAT_BASE_URL/chat" \
|
|
35
|
+
--header @- \
|
|
36
|
+
--header "Accept: text/event-stream" \
|
|
37
|
+
--header "Content-Type: application/json" \
|
|
38
|
+
--data '{"input":"What is love? Answer conversationally."}'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The event order is `start`, `progress`, zero or more `delta` events, then one
|
|
42
|
+
`terminal`. Only `terminal: completed` is success. A failed terminal is
|
|
43
|
+
preceded by a redacted `error` event and must not be treated as a partial
|
|
44
|
+
answer.
|
|
45
|
+
|
|
29
46
|
Copy the returned `conversationId`, then send a continuation:
|
|
30
47
|
|
|
31
48
|
```bash
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
@@ -12,6 +12,7 @@ const MAX_PROTOCOL_STDOUT_BYTES = 256 * 1024;
|
|
|
12
12
|
const MAX_PROTOCOL_STDERR_BYTES = 64 * 1024;
|
|
13
13
|
const TURN_TIMEOUT_MS = 120_000;
|
|
14
14
|
const TERMINATION_GRACE_MS = 2_000;
|
|
15
|
+
const KEEPALIVE_INTERVAL_MS = 15_000;
|
|
15
16
|
const CONVERSATIONAL_INSTRUCTION =
|
|
16
17
|
"Provide a conversational answer only. Do not inspect or edit files, run commands, call tools, or access external resources.";
|
|
17
18
|
|
|
@@ -65,6 +66,70 @@ function sendError(response, status, code) {
|
|
|
65
66
|
sendJson(response, status, { error: { code } });
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function acceptsEventStream(request) {
|
|
70
|
+
if (headerOccurrences(request, "accept") > 1) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const value = request.headers.accept;
|
|
74
|
+
return (
|
|
75
|
+
typeof value === "string" &&
|
|
76
|
+
value
|
|
77
|
+
.split(",")
|
|
78
|
+
.some((entry) => entry.trim().split(";", 1)[0] === "text/event-stream")
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function startEventStream(response, keepaliveIntervalMs) {
|
|
83
|
+
let ended = false;
|
|
84
|
+
response.writeHead(200, {
|
|
85
|
+
"Cache-Control": "no-store",
|
|
86
|
+
Connection: "keep-alive",
|
|
87
|
+
"Content-Encoding": "none",
|
|
88
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
89
|
+
"X-Accel-Buffering": "no",
|
|
90
|
+
"X-Content-Type-Options": "nosniff",
|
|
91
|
+
"X-Relmio-Stream": "v1",
|
|
92
|
+
});
|
|
93
|
+
const send = (event, data) => {
|
|
94
|
+
if (ended || response.writableEnded || response.destroyed) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
98
|
+
};
|
|
99
|
+
const keepalive = setInterval(() => {
|
|
100
|
+
if (!ended && !response.writableEnded && !response.destroyed) {
|
|
101
|
+
response.write(": keepalive\n\n");
|
|
102
|
+
}
|
|
103
|
+
}, keepaliveIntervalMs);
|
|
104
|
+
keepalive.unref?.();
|
|
105
|
+
send("start", { requestId: randomUUID() });
|
|
106
|
+
return {
|
|
107
|
+
send,
|
|
108
|
+
complete(result) {
|
|
109
|
+
if (ended) return;
|
|
110
|
+
send("terminal", {
|
|
111
|
+
outcome: "completed",
|
|
112
|
+
conversationId: result.conversationId,
|
|
113
|
+
});
|
|
114
|
+
ended = true;
|
|
115
|
+
clearInterval(keepalive);
|
|
116
|
+
response.end();
|
|
117
|
+
},
|
|
118
|
+
fail(code = "upstream_failed", retryable = true) {
|
|
119
|
+
if (ended) return;
|
|
120
|
+
send("error", { code, retryable });
|
|
121
|
+
send("terminal", { outcome: "failed" });
|
|
122
|
+
ended = true;
|
|
123
|
+
clearInterval(keepalive);
|
|
124
|
+
response.end();
|
|
125
|
+
},
|
|
126
|
+
dispose() {
|
|
127
|
+
ended = true;
|
|
128
|
+
clearInterval(keepalive);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
68
133
|
function hasValidBearer(request, verifier) {
|
|
69
134
|
if (headerOccurrences(request, "authorization") !== 1) {
|
|
70
135
|
return false;
|
|
@@ -196,6 +261,8 @@ function createAppServerOperation({
|
|
|
196
261
|
input,
|
|
197
262
|
conversationId,
|
|
198
263
|
packageVersion,
|
|
264
|
+
onEvent,
|
|
265
|
+
onFailure,
|
|
199
266
|
signal,
|
|
200
267
|
spawnProcess,
|
|
201
268
|
terminationGraceMs,
|
|
@@ -241,6 +308,7 @@ function createAppServerOperation({
|
|
|
241
308
|
let finalOutput = null;
|
|
242
309
|
let latestDeltaItemId = null;
|
|
243
310
|
let deltaBytes = 0;
|
|
311
|
+
let emittedDeltaBytes = 0;
|
|
244
312
|
const deltaOutputs = new Map();
|
|
245
313
|
let phase = "initializing";
|
|
246
314
|
let outcome = null;
|
|
@@ -298,6 +366,9 @@ function createAppServerOperation({
|
|
|
298
366
|
return;
|
|
299
367
|
}
|
|
300
368
|
outcome = { error, result };
|
|
369
|
+
if (error) {
|
|
370
|
+
onFailure?.(error.message === "timeout" ? "timeout" : "upstream_failed");
|
|
371
|
+
}
|
|
301
372
|
signal?.removeEventListener?.("abort", abortOperation);
|
|
302
373
|
clearTimeout(timeout);
|
|
303
374
|
timeout = undefined;
|
|
@@ -316,6 +387,7 @@ function createAppServerOperation({
|
|
|
316
387
|
};
|
|
317
388
|
const startThread = () => {
|
|
318
389
|
phase = "thread";
|
|
390
|
+
onEvent?.("progress", { phase: "starting_thread" });
|
|
319
391
|
write({
|
|
320
392
|
id: 1,
|
|
321
393
|
method: conversationId ? "thread/resume" : "thread/start",
|
|
@@ -337,6 +409,7 @@ function createAppServerOperation({
|
|
|
337
409
|
};
|
|
338
410
|
const startTurn = () => {
|
|
339
411
|
phase = "turn";
|
|
412
|
+
onEvent?.("progress", { phase: "starting_turn" });
|
|
340
413
|
write({
|
|
341
414
|
id: 2,
|
|
342
415
|
method: "turn/start",
|
|
@@ -418,11 +491,13 @@ function createAppServerOperation({
|
|
|
418
491
|
return;
|
|
419
492
|
}
|
|
420
493
|
deltaBytes += partBytes;
|
|
494
|
+
emittedDeltaBytes += partBytes;
|
|
421
495
|
latestDeltaItemId = params.itemId;
|
|
422
496
|
deltaOutputs.set(
|
|
423
497
|
params.itemId,
|
|
424
498
|
`${deltaOutputs.get(params.itemId) ?? ""}${params.delta}`,
|
|
425
499
|
);
|
|
500
|
+
onEvent?.("delta", { text: params.delta });
|
|
426
501
|
return;
|
|
427
502
|
}
|
|
428
503
|
if (message.method === "item/completed") {
|
|
@@ -468,6 +543,9 @@ function createAppServerOperation({
|
|
|
468
543
|
failProtocol();
|
|
469
544
|
return;
|
|
470
545
|
}
|
|
546
|
+
if (emittedDeltaBytes === 0) {
|
|
547
|
+
onEvent?.("delta", { text: output });
|
|
548
|
+
}
|
|
471
549
|
settle(null, { conversationId: threadId, output });
|
|
472
550
|
return;
|
|
473
551
|
}
|
|
@@ -552,6 +630,7 @@ function createAppServerOperation({
|
|
|
552
630
|
},
|
|
553
631
|
},
|
|
554
632
|
});
|
|
633
|
+
onEvent?.("progress", { phase: "initializing" });
|
|
555
634
|
});
|
|
556
635
|
}
|
|
557
636
|
|
|
@@ -603,6 +682,7 @@ export async function startCodexChatGateway({
|
|
|
603
682
|
spawnProcess = spawn,
|
|
604
683
|
terminationGraceMs = TERMINATION_GRACE_MS,
|
|
605
684
|
turnTimeoutMs = TURN_TIMEOUT_MS,
|
|
685
|
+
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
|
|
606
686
|
} = {}) {
|
|
607
687
|
if (!isLoopbackListenHost(host)) {
|
|
608
688
|
throw new TypeError("Codex Chat must listen on a literal loopback-safe host.");
|
|
@@ -627,6 +707,13 @@ export async function startCodexChatGateway({
|
|
|
627
707
|
) {
|
|
628
708
|
throw new TypeError("The Codex Chat turn timeout is invalid.");
|
|
629
709
|
}
|
|
710
|
+
if (
|
|
711
|
+
!Number.isSafeInteger(keepaliveIntervalMs) ||
|
|
712
|
+
keepaliveIntervalMs < 1 ||
|
|
713
|
+
keepaliveIntervalMs > 60_000
|
|
714
|
+
) {
|
|
715
|
+
throw new TypeError("The Codex Chat keepalive interval is invalid.");
|
|
716
|
+
}
|
|
630
717
|
const verifier = validateTokenVerifier(tokenVerifier);
|
|
631
718
|
const safePackageVersion = validatePackageVersion(packageVersion);
|
|
632
719
|
let activeOperation = false;
|
|
@@ -673,6 +760,7 @@ export async function startCodexChatGateway({
|
|
|
673
760
|
activeOperation = true;
|
|
674
761
|
const controller = new AbortController();
|
|
675
762
|
let disconnected = false;
|
|
763
|
+
let eventStream;
|
|
676
764
|
const onDisconnect = () => {
|
|
677
765
|
if (request.aborted || !response.writableEnded) {
|
|
678
766
|
disconnected = true;
|
|
@@ -686,9 +774,16 @@ export async function startCodexChatGateway({
|
|
|
686
774
|
if (disconnected) {
|
|
687
775
|
throw new Error("unavailable");
|
|
688
776
|
}
|
|
777
|
+
if (acceptsEventStream(request)) {
|
|
778
|
+
eventStream = startEventStream(response, keepaliveIntervalMs);
|
|
779
|
+
}
|
|
689
780
|
return createAppServerOperation({
|
|
690
781
|
...chat,
|
|
691
782
|
packageVersion: safePackageVersion,
|
|
783
|
+
onEvent: eventStream?.send,
|
|
784
|
+
onFailure: eventStream
|
|
785
|
+
? (code) => eventStream.fail(code)
|
|
786
|
+
: undefined,
|
|
692
787
|
signal: controller.signal,
|
|
693
788
|
spawnProcess,
|
|
694
789
|
terminationGraceMs,
|
|
@@ -697,13 +792,18 @@ export async function startCodexChatGateway({
|
|
|
697
792
|
})
|
|
698
793
|
.then((result) => {
|
|
699
794
|
if (!disconnected && !response.writableEnded) {
|
|
700
|
-
|
|
795
|
+
if (eventStream) eventStream.complete(result);
|
|
796
|
+
else sendJson(response, 200, result);
|
|
701
797
|
}
|
|
702
798
|
})
|
|
703
799
|
.catch((error) => {
|
|
704
800
|
if (disconnected || response.writableEnded) {
|
|
705
801
|
return;
|
|
706
802
|
}
|
|
803
|
+
if (eventStream) {
|
|
804
|
+
eventStream.fail();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
707
807
|
const status = error?.status;
|
|
708
808
|
const code = error?.code;
|
|
709
809
|
if (status === 413 || status === 499 || code === "invalid_json" || code === "invalid_request") {
|
|
@@ -713,6 +813,7 @@ export async function startCodexChatGateway({
|
|
|
713
813
|
sendError(response, 503, "unavailable");
|
|
714
814
|
})
|
|
715
815
|
.finally(() => {
|
|
816
|
+
eventStream?.dispose();
|
|
716
817
|
activeOperation = false;
|
|
717
818
|
});
|
|
718
819
|
});
|
|
@@ -15,7 +15,7 @@ const MAX_ENDPOINT_LENGTH = 64;
|
|
|
15
15
|
const MAX_CIPHERTEXT_LENGTH = 4_096;
|
|
16
16
|
const MAX_INPUT_LENGTH = 8_192;
|
|
17
17
|
const MAX_CONVERSATION_ID_LENGTH = 160;
|
|
18
|
-
const MAX_RESPONSE_BYTES =
|
|
18
|
+
const MAX_RESPONSE_BYTES = 512 * 1_024;
|
|
19
19
|
const MAX_OUTPUT_LENGTH = 12 * 1_024;
|
|
20
20
|
|
|
21
21
|
function requestError(message, statusCode = 400) {
|
|
@@ -173,6 +173,130 @@ function parseAdapterResponse(text) {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
function parseEventBlock(block) {
|
|
177
|
+
const dataLines = [];
|
|
178
|
+
let event;
|
|
179
|
+
for (const line of block.split(/\r?\n/u)) {
|
|
180
|
+
if (line.startsWith("event:")) {
|
|
181
|
+
event = line.slice(6).trim();
|
|
182
|
+
} else if (line.startsWith("data:")) {
|
|
183
|
+
dataLines.push(line.slice(5).trimStart());
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!event || dataLines.length === 0) {
|
|
187
|
+
throw adapterError();
|
|
188
|
+
}
|
|
189
|
+
let data;
|
|
190
|
+
try {
|
|
191
|
+
data = JSON.parse(dataLines.join("\n"));
|
|
192
|
+
} catch {
|
|
193
|
+
throw adapterError();
|
|
194
|
+
}
|
|
195
|
+
if (!isPlainObject(data)) {
|
|
196
|
+
throw adapterError();
|
|
197
|
+
}
|
|
198
|
+
return { event, data };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function consumeAdapterStream(response, onEvent) {
|
|
202
|
+
if (
|
|
203
|
+
!/^text\/event-stream(?:\s*;|$)/iu.test(
|
|
204
|
+
response.headers?.get?.("content-type") ?? "",
|
|
205
|
+
) ||
|
|
206
|
+
response.headers?.get?.("x-relmio-stream") !== "v1"
|
|
207
|
+
) {
|
|
208
|
+
throw adapterError();
|
|
209
|
+
}
|
|
210
|
+
const reader = response.body?.getReader?.();
|
|
211
|
+
if (!reader) {
|
|
212
|
+
throw adapterError();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const decoder = new TextDecoder();
|
|
216
|
+
let buffer = "";
|
|
217
|
+
let bytes = 0;
|
|
218
|
+
let conversationId;
|
|
219
|
+
let failed = false;
|
|
220
|
+
let output = "";
|
|
221
|
+
let terminal = false;
|
|
222
|
+
const processBlock = (block) => {
|
|
223
|
+
if (!block.trim() || block.trimStart().startsWith(":")) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const { event, data } = parseEventBlock(block);
|
|
227
|
+
if (event === "start") {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (event === "progress") {
|
|
231
|
+
onEvent("progress", { phase: "working" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (event === "delta") {
|
|
235
|
+
if (
|
|
236
|
+
typeof data.text !== "string" ||
|
|
237
|
+
data.text.length === 0 ||
|
|
238
|
+
data.text.includes("\0") ||
|
|
239
|
+
output.length + data.text.length > MAX_OUTPUT_LENGTH
|
|
240
|
+
) {
|
|
241
|
+
throw adapterError();
|
|
242
|
+
}
|
|
243
|
+
output += data.text;
|
|
244
|
+
onEvent("delta", { text: data.text });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (event === "error") {
|
|
248
|
+
failed = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (event === "terminal") {
|
|
252
|
+
if (terminal || !["completed", "failed"].includes(data.outcome)) {
|
|
253
|
+
throw adapterError();
|
|
254
|
+
}
|
|
255
|
+
terminal = true;
|
|
256
|
+
failed ||= data.outcome !== "completed";
|
|
257
|
+
if (!failed) {
|
|
258
|
+
if (!isBoundedText(data.conversationId, MAX_CONVERSATION_ID_LENGTH)) {
|
|
259
|
+
throw adapterError();
|
|
260
|
+
}
|
|
261
|
+
conversationId = data.conversationId;
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
throw adapterError();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
for (;;) {
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done) break;
|
|
272
|
+
bytes += value.byteLength;
|
|
273
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
274
|
+
throw adapterError();
|
|
275
|
+
}
|
|
276
|
+
buffer += decoder.decode(value, { stream: true });
|
|
277
|
+
const blocks = buffer.split(/\r?\n\r?\n/u);
|
|
278
|
+
buffer = blocks.pop() ?? "";
|
|
279
|
+
for (const block of blocks) processBlock(block);
|
|
280
|
+
}
|
|
281
|
+
buffer += decoder.decode();
|
|
282
|
+
if (buffer.trim()) processBlock(buffer);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
try {
|
|
285
|
+
await reader.cancel();
|
|
286
|
+
} catch {
|
|
287
|
+
// The adapter stream may already be closed after a malformed terminal.
|
|
288
|
+
}
|
|
289
|
+
throw error;
|
|
290
|
+
} finally {
|
|
291
|
+
reader.releaseLock?.();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (failed || !terminal || !conversationId || output.length === 0) {
|
|
295
|
+
throw adapterError();
|
|
296
|
+
}
|
|
297
|
+
return { conversationId, output };
|
|
298
|
+
}
|
|
299
|
+
|
|
176
300
|
function isTimeout(error) {
|
|
177
301
|
return error?.name === "AbortError" || error?.name === "TimeoutError";
|
|
178
302
|
}
|
|
@@ -265,8 +389,12 @@ export function createLocalChatTestService({
|
|
|
265
389
|
}
|
|
266
390
|
},
|
|
267
391
|
|
|
268
|
-
async message(untrustedRequest) {
|
|
392
|
+
async message(untrustedRequest, options = {}) {
|
|
269
393
|
const request = validateMessageRequest(untrustedRequest);
|
|
394
|
+
const onEvent =
|
|
395
|
+
typeof options.onEvent === "function" ? options.onEvent : null;
|
|
396
|
+
const externalSignal =
|
|
397
|
+
options.signal instanceof AbortSignal ? options.signal : null;
|
|
270
398
|
const session = getLiveSession(request.keyId);
|
|
271
399
|
if (session.inFlight) {
|
|
272
400
|
throw requestError("Wait for the current test message to finish.", 409);
|
|
@@ -275,6 +403,7 @@ export function createLocalChatTestService({
|
|
|
275
403
|
let encryptedCredential;
|
|
276
404
|
let decryptedCredential;
|
|
277
405
|
let authorization;
|
|
406
|
+
let abortFromCaller;
|
|
278
407
|
let timeout;
|
|
279
408
|
try {
|
|
280
409
|
encryptedCredential = decodeCiphertext(request.encryptedCredential);
|
|
@@ -302,6 +431,14 @@ export function createLocalChatTestService({
|
|
|
302
431
|
let response;
|
|
303
432
|
try {
|
|
304
433
|
session.abortController = new AbortController();
|
|
434
|
+
abortFromCaller = () => session.abortController?.abort();
|
|
435
|
+
if (externalSignal?.aborted) {
|
|
436
|
+
abortFromCaller();
|
|
437
|
+
} else {
|
|
438
|
+
externalSignal?.addEventListener("abort", abortFromCaller, {
|
|
439
|
+
once: true,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
305
442
|
timeout = setTimeout(
|
|
306
443
|
() => session.abortController?.abort(),
|
|
307
444
|
requestTimeoutMs,
|
|
@@ -309,7 +446,7 @@ export function createLocalChatTestService({
|
|
|
309
446
|
response = await fetchImpl(`${request.endpointBaseUrl}/chat`, {
|
|
310
447
|
method: "POST",
|
|
311
448
|
headers: {
|
|
312
|
-
Accept: "application/json",
|
|
449
|
+
Accept: onEvent ? "text/event-stream" : "application/json",
|
|
313
450
|
Authorization: authorization,
|
|
314
451
|
"Content-Type": "application/json",
|
|
315
452
|
},
|
|
@@ -328,9 +465,12 @@ export function createLocalChatTestService({
|
|
|
328
465
|
if (!response?.ok || response.status < 200 || response.status >= 300) {
|
|
329
466
|
throw adapterError();
|
|
330
467
|
}
|
|
331
|
-
return
|
|
468
|
+
return onEvent
|
|
469
|
+
? await consumeAdapterStream(response, onEvent)
|
|
470
|
+
: parseAdapterResponse(await readBoundedResponse(response));
|
|
332
471
|
} finally {
|
|
333
472
|
clearTimeout(timeout);
|
|
473
|
+
externalSignal?.removeEventListener("abort", abortFromCaller);
|
|
334
474
|
authorization = undefined;
|
|
335
475
|
encryptedCredential?.fill(0);
|
|
336
476
|
decryptedCredential?.fill(0);
|
package/src/ui/local.css
CHANGED
|
@@ -6,6 +6,29 @@
|
|
|
6
6
|
scroll-behavior: smooth;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
.local-wizard .local-eyebrow {
|
|
10
|
+
margin: 0 0 0.4rem;
|
|
11
|
+
color: var(--accent-deep);
|
|
12
|
+
font: 800 0.6875rem/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
13
|
+
letter-spacing: 0.12em;
|
|
14
|
+
text-transform: uppercase;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.local-wizard .rail {
|
|
18
|
+
padding: 1rem;
|
|
19
|
+
border: 1px solid var(--border);
|
|
20
|
+
border-radius: var(--radius);
|
|
21
|
+
background: color-mix(in srgb, var(--surface) 78%, transparent);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.local-wizard .steps li {
|
|
25
|
+
min-height: 2.5rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.local-wizard .steps li[aria-current="step"] em {
|
|
29
|
+
color: var(--text);
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
.local-wizard .status-row {
|
|
10
33
|
margin-bottom: 1rem;
|
|
11
34
|
}
|
|
@@ -369,10 +392,14 @@
|
|
|
369
392
|
display: grid;
|
|
370
393
|
gap: 1rem;
|
|
371
394
|
margin-top: 1.25rem;
|
|
372
|
-
padding: 1rem;
|
|
395
|
+
padding: clamp(1rem, 2vw, 1.5rem);
|
|
373
396
|
border: 1px solid var(--accent-border);
|
|
374
397
|
border-radius: var(--radius);
|
|
375
|
-
background:
|
|
398
|
+
background:
|
|
399
|
+
linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
|
|
400
|
+
linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px),
|
|
401
|
+
var(--accent-soft);
|
|
402
|
+
background-size: 28px 28px;
|
|
376
403
|
}
|
|
377
404
|
|
|
378
405
|
.chat-tester-heading {
|
|
@@ -395,6 +422,42 @@
|
|
|
395
422
|
margin: 0;
|
|
396
423
|
}
|
|
397
424
|
|
|
425
|
+
.chat-tester-route {
|
|
426
|
+
display: grid;
|
|
427
|
+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
|
|
428
|
+
align-items: center;
|
|
429
|
+
gap: 0.625rem;
|
|
430
|
+
padding: 0.75rem;
|
|
431
|
+
border: 1px solid var(--accent-border);
|
|
432
|
+
border-radius: var(--radius-sm);
|
|
433
|
+
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.chat-tester-route > span {
|
|
437
|
+
display: grid;
|
|
438
|
+
gap: 0.2rem;
|
|
439
|
+
min-width: 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.chat-tester-route strong,
|
|
443
|
+
.chat-tester-route small {
|
|
444
|
+
overflow-wrap: anywhere;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.chat-tester-route strong {
|
|
448
|
+
font-size: 0.8125rem;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.chat-tester-route small {
|
|
452
|
+
color: var(--text-muted);
|
|
453
|
+
font: 0.6875rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.chat-tester-route i {
|
|
457
|
+
color: var(--accent-deep);
|
|
458
|
+
font-style: normal;
|
|
459
|
+
}
|
|
460
|
+
|
|
398
461
|
.chat-tester-form fieldset {
|
|
399
462
|
display: grid;
|
|
400
463
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
@@ -460,6 +523,16 @@
|
|
|
460
523
|
background: var(--accent-soft);
|
|
461
524
|
}
|
|
462
525
|
|
|
526
|
+
.chat-tester-turn-incomplete {
|
|
527
|
+
border-color: var(--warning-border);
|
|
528
|
+
border-style: dashed;
|
|
529
|
+
background: var(--warning-soft);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.chat-tester-turn-incomplete strong {
|
|
533
|
+
color: var(--warning);
|
|
534
|
+
}
|
|
535
|
+
|
|
463
536
|
.chat-tester-turn strong {
|
|
464
537
|
display: block;
|
|
465
538
|
margin-bottom: 0.25rem;
|
|
@@ -497,6 +570,14 @@
|
|
|
497
570
|
flex-direction: column;
|
|
498
571
|
}
|
|
499
572
|
|
|
573
|
+
.chat-tester-route {
|
|
574
|
+
grid-template-columns: minmax(0, 1fr);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.chat-tester-route i {
|
|
578
|
+
transform: rotate(90deg);
|
|
579
|
+
}
|
|
580
|
+
|
|
500
581
|
.review-details div {
|
|
501
582
|
grid-template-columns: minmax(0, 1fr);
|
|
502
583
|
gap: 0.2rem;
|
package/src/ui/local.html
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<title>Relmio | Local Endpoint Setup</title>
|
|
11
11
|
<link
|
|
12
12
|
rel="icon"
|
|
13
|
-
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='%23137c74' d='
|
|
13
|
+
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='%23137c74' d='M8 13 28 5v38c0 4-2 7-6 9L8 59Z'/%3E%3Cpath fill='%2312211f' d='M36 5l11 5c6 3 9 8 9 15v9c0 3 1 5 3 7l3 3c3 3 3 8 0 11s-8 3-11 0L40 44c-3-3-4-6-4-10Z'/%3E%3C/svg%3E"
|
|
14
14
|
/>
|
|
15
15
|
<link rel="stylesheet" href="/styles.css" />
|
|
16
16
|
<link rel="stylesheet" href="/local.css" />
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
aria-hidden="true"
|
|
29
29
|
focusable="false"
|
|
30
30
|
>
|
|
31
|
-
<path fill="#137c74" d="
|
|
32
|
-
<path fill="#12211f" d="
|
|
31
|
+
<path fill="#137c74" d="M8 13 28 5v38c0 4-2 7-6 9L8 59Z" />
|
|
32
|
+
<path fill="#12211f" d="M36 5l11 5c6 3 9 8 9 15v9c0 3 1 5 3 7l3 3c3 3 3 8 0 11s-8 3-11 0L40 44c-3-3-4-6-4-10Z" />
|
|
33
33
|
</svg>
|
|
34
34
|
<span>Relmio</span>
|
|
35
35
|
</div>
|
|
@@ -90,9 +90,10 @@
|
|
|
90
90
|
<main id="main-content" class="shell" tabindex="-1">
|
|
91
91
|
<aside class="rail" aria-label="Local setup progress and safety">
|
|
92
92
|
<section class="intro" aria-labelledby="page-title">
|
|
93
|
-
<
|
|
93
|
+
<p class="local-eyebrow">Local pre-flight</p>
|
|
94
|
+
<h1 id="page-title">One boundary at a time</h1>
|
|
94
95
|
<p class="lede">
|
|
95
|
-
Choose the official credential path that matches your client. All
|
|
96
|
+
Run Relmio on localhost. Choose the official credential path that matches your client. All
|
|
96
97
|
three options run in hardened Docker containers and bind only to
|
|
97
98
|
<code>127.0.0.1</code>.
|
|
98
99
|
</p>
|
|
@@ -519,7 +520,7 @@
|
|
|
519
520
|
>
|
|
520
521
|
<div class="chat-tester-heading">
|
|
521
522
|
<div>
|
|
522
|
-
<p class="section-label">
|
|
523
|
+
<p class="section-label">Streaming verification console</p>
|
|
523
524
|
<h3 id="chat-tester-title">Test this local Chat Adapter</h3>
|
|
524
525
|
<p>
|
|
525
526
|
Relmio sends this browser only to its same-origin local wizard.
|
|
@@ -535,6 +536,14 @@
|
|
|
535
536
|
</button>
|
|
536
537
|
</div>
|
|
537
538
|
|
|
539
|
+
<div class="chat-tester-route" aria-label="Request route">
|
|
540
|
+
<span><strong>Browser</strong><small>Same origin</small></span>
|
|
541
|
+
<i aria-hidden="true">→</i>
|
|
542
|
+
<span><strong>Wizard relay</strong><small>Encrypted session</small></span>
|
|
543
|
+
<i aria-hidden="true">→</i>
|
|
544
|
+
<span><strong>Chat Adapter</strong><small>127.0.0.1 only</small></span>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
538
547
|
<form id="chat-tester-secure-form" class="chat-tester-form">
|
|
539
548
|
<fieldset>
|
|
540
549
|
<legend>Secure a temporary test session</legend>
|
package/src/ui/local.js
CHANGED
|
@@ -131,6 +131,123 @@ async function api(path, { method = "GET", body } = {}) {
|
|
|
131
131
|
return result;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
function parseRelmioStreamEvent(block) {
|
|
135
|
+
const dataLines = [];
|
|
136
|
+
let event;
|
|
137
|
+
for (const line of block.split(/\r?\n/u)) {
|
|
138
|
+
if (line.startsWith("event:")) {
|
|
139
|
+
event = line.slice(6).trim();
|
|
140
|
+
} else if (line.startsWith("data:")) {
|
|
141
|
+
dataLines.push(line.slice(5).trimStart());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!event || dataLines.length === 0) {
|
|
145
|
+
throw new Error("The local wizard returned an unreadable stream.");
|
|
146
|
+
}
|
|
147
|
+
let data;
|
|
148
|
+
try {
|
|
149
|
+
data = JSON.parse(dataLines.join("\n"));
|
|
150
|
+
} catch {
|
|
151
|
+
throw new Error("The local wizard returned an unreadable stream.");
|
|
152
|
+
}
|
|
153
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
154
|
+
throw new Error("The local wizard returned an unexpected stream event.");
|
|
155
|
+
}
|
|
156
|
+
return { event, data };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function streamChatTesterMessage(body, onEvent) {
|
|
160
|
+
if (!token) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
"This wizard link is incomplete. Close this tab and open the full URL printed by the active Relmio terminal.",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
let response;
|
|
166
|
+
try {
|
|
167
|
+
response = await fetch("/api/local/chat-test/message", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: {
|
|
170
|
+
Accept: "text/event-stream",
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
"X-Setup-Token": token,
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error("The local Relmio wizard is not reachable. Keep its terminal open and try again.");
|
|
178
|
+
}
|
|
179
|
+
if (
|
|
180
|
+
!response.ok ||
|
|
181
|
+
!response.body ||
|
|
182
|
+
!/^text\/event-stream(?:\s*;|$)/iu.test(response.headers.get("content-type") ?? "") ||
|
|
183
|
+
response.headers.get("x-relmio-stream") !== "v1"
|
|
184
|
+
) {
|
|
185
|
+
throw new Error("The local wizard could not start a safe response stream.");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const reader = response.body.getReader();
|
|
189
|
+
const decoder = new TextDecoder();
|
|
190
|
+
let buffer = "";
|
|
191
|
+
let exhausted = false;
|
|
192
|
+
let streamError;
|
|
193
|
+
let terminal;
|
|
194
|
+
const consume = (block) => {
|
|
195
|
+
if (!block.trim() || block.trimStart().startsWith(":")) return;
|
|
196
|
+
if (terminal) {
|
|
197
|
+
throw new Error("The local wizard sent data after the terminal event.");
|
|
198
|
+
}
|
|
199
|
+
const item = parseRelmioStreamEvent(block);
|
|
200
|
+
if (item.event === "start") return;
|
|
201
|
+
if (item.event === "progress" || item.event === "delta") {
|
|
202
|
+
onEvent(item.event, item.data);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (item.event === "error") {
|
|
206
|
+
const messages = {
|
|
207
|
+
timeout: "The adapter test took too long. Try again.",
|
|
208
|
+
upstream_failed: "The local adapter could not complete this response.",
|
|
209
|
+
};
|
|
210
|
+
streamError = messages[item.data.code] ?? messages.upstream_failed;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (item.event === "terminal") {
|
|
214
|
+
terminal = item.data;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
throw new Error("The local wizard returned an unexpected stream event.");
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
for (;;) {
|
|
222
|
+
const { done, value } = await reader.read();
|
|
223
|
+
if (done) {
|
|
224
|
+
exhausted = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
buffer += decoder.decode(value, { stream: true });
|
|
228
|
+
const blocks = buffer.split(/\r?\n\r?\n/u);
|
|
229
|
+
buffer = blocks.pop() ?? "";
|
|
230
|
+
for (const block of blocks) consume(block);
|
|
231
|
+
}
|
|
232
|
+
buffer += decoder.decode();
|
|
233
|
+
if (buffer.trim()) consume(buffer);
|
|
234
|
+
} finally {
|
|
235
|
+
if (!exhausted) {
|
|
236
|
+
try {
|
|
237
|
+
await reader.cancel();
|
|
238
|
+
} catch {
|
|
239
|
+
// The same-origin stream may already have closed after a parse failure.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
reader.releaseLock();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!terminal || terminal.outcome !== "completed" || typeof terminal.conversationId !== "string") {
|
|
246
|
+
throw new Error(streamError ?? "The adapter response ended before completion.");
|
|
247
|
+
}
|
|
248
|
+
return { conversationId: terminal.conversationId };
|
|
249
|
+
}
|
|
250
|
+
|
|
134
251
|
function selectedTarget() {
|
|
135
252
|
return document.querySelector('input[name="target"]:checked')?.value;
|
|
136
253
|
}
|
|
@@ -341,6 +458,7 @@ function appendChatTesterTurn(kind, text) {
|
|
|
341
458
|
item.className = `chat-tester-turn chat-tester-turn-${kind}`;
|
|
342
459
|
item.append(heading, content);
|
|
343
460
|
element("chat-tester-transcript").append(item);
|
|
461
|
+
return content;
|
|
344
462
|
}
|
|
345
463
|
|
|
346
464
|
function clearChatTesterState() {
|
|
@@ -756,13 +874,13 @@ element("chat-tester-message-form").addEventListener("submit", async (event) =>
|
|
|
756
874
|
const generation = state.chatTester.generation;
|
|
757
875
|
input.value = "";
|
|
758
876
|
appendChatTesterTurn("user", text);
|
|
877
|
+
const assistantContent = appendChatTesterTurn("assistant", "");
|
|
759
878
|
resetButton.disabled = true;
|
|
760
|
-
setBusy(button, true, "
|
|
761
|
-
setChatTesterStatus("
|
|
879
|
+
setBusy(button, true, "Streaming response…");
|
|
880
|
+
setChatTesterStatus("Connecting to the loopback adapter through the secured local wizard…");
|
|
762
881
|
try {
|
|
763
|
-
const result = await
|
|
764
|
-
|
|
765
|
-
body: {
|
|
882
|
+
const result = await streamChatTesterMessage(
|
|
883
|
+
{
|
|
766
884
|
endpointBaseUrl: state.chatTester.endpointBaseUrl,
|
|
767
885
|
keyId: state.chatTester.keyId,
|
|
768
886
|
encryptedCredential: state.chatTester.encryptedCredential,
|
|
@@ -771,23 +889,37 @@ element("chat-tester-message-form").addEventListener("submit", async (event) =>
|
|
|
771
889
|
? { conversationId: state.chatTester.conversationId }
|
|
772
890
|
: {}),
|
|
773
891
|
},
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
892
|
+
(event, data) => {
|
|
893
|
+
if (state.chatTester.generation !== generation) return;
|
|
894
|
+
if (event === "progress") {
|
|
895
|
+
setChatTesterStatus("The adapter is working on the response…");
|
|
896
|
+
} else if (event === "delta" && typeof data.text === "string") {
|
|
897
|
+
assistantContent.textContent += data.text;
|
|
898
|
+
setChatTesterStatus("Streaming the adapter response…");
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
);
|
|
781
902
|
if (state.chatTester.generation !== generation) {
|
|
782
903
|
return;
|
|
783
904
|
}
|
|
784
905
|
state.chatTester.conversationId = result.conversationId;
|
|
785
|
-
|
|
906
|
+
if (!assistantContent.textContent) {
|
|
907
|
+
throw new Error("The local adapter completed without a visible response.");
|
|
908
|
+
}
|
|
786
909
|
setChatTesterStatus("Response received. Continue this conversation or forget the tester.");
|
|
787
910
|
} catch (error) {
|
|
788
911
|
if (state.chatTester.generation === generation) {
|
|
912
|
+
if (!assistantContent.textContent) {
|
|
913
|
+
assistantContent.parentElement?.remove();
|
|
914
|
+
setChatTesterStatus("No completed adapter response was added. You can retry or forget this tester.");
|
|
915
|
+
} else {
|
|
916
|
+
const assistantTurn = assistantContent.parentElement;
|
|
917
|
+
assistantTurn?.classList.add("chat-tester-turn-incomplete");
|
|
918
|
+
const heading = assistantTurn?.querySelector("strong");
|
|
919
|
+
if (heading) heading.textContent = "Local adapter · incomplete";
|
|
920
|
+
setChatTesterStatus("The response stopped before completion. Partial text is marked incomplete and was not accepted.");
|
|
921
|
+
}
|
|
789
922
|
showChatTesterError(error);
|
|
790
|
-
setChatTesterStatus("No adapter response was added. You can retry or forget this tester.");
|
|
791
923
|
}
|
|
792
924
|
} finally {
|
|
793
925
|
resetButton.disabled = false;
|
package/src/web/server.js
CHANGED
|
@@ -115,6 +115,64 @@ function sendJson(response, statusCode, body) {
|
|
|
115
115
|
response.end(contents);
|
|
116
116
|
}
|
|
117
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
|
+
|
|
118
176
|
function tokenMatches(actual, expected) {
|
|
119
177
|
if (typeof actual !== "string") {
|
|
120
178
|
return false;
|
|
@@ -691,13 +749,35 @@ async function handleApi(request, response, path, state) {
|
|
|
691
749
|
requireLiveLocalAction(state, "Local chat testing");
|
|
692
750
|
requireReadyLocalChatTester(state);
|
|
693
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);
|
|
694
759
|
try {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
+
}),
|
|
699
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
|
+
}
|
|
700
778
|
} finally {
|
|
779
|
+
response.off("close", abortOnClose);
|
|
780
|
+
stream?.dispose();
|
|
701
781
|
if (body && typeof body === "object") {
|
|
702
782
|
body.encryptedCredential = undefined;
|
|
703
783
|
body.input = undefined;
|