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/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>
|
|
@@ -142,6 +143,13 @@
|
|
|
142
143
|
hidden
|
|
143
144
|
>
|
|
144
145
|
<span id="global-error-text"></span>
|
|
146
|
+
<a
|
|
147
|
+
id="local-image-build-troubleshooting"
|
|
148
|
+
href="https://relmio.vercel.app/docs/troubleshooting#local-image-build-failed"
|
|
149
|
+
target="_blank"
|
|
150
|
+
rel="noopener noreferrer"
|
|
151
|
+
hidden
|
|
152
|
+
>View troubleshooting</a>
|
|
145
153
|
</div>
|
|
146
154
|
</div>
|
|
147
155
|
</aside>
|
|
@@ -504,6 +512,106 @@
|
|
|
504
512
|
</div>
|
|
505
513
|
</section>
|
|
506
514
|
|
|
515
|
+
<section
|
|
516
|
+
id="chat-tester"
|
|
517
|
+
class="chat-tester"
|
|
518
|
+
aria-labelledby="chat-tester-title"
|
|
519
|
+
hidden
|
|
520
|
+
>
|
|
521
|
+
<div class="chat-tester-heading">
|
|
522
|
+
<div>
|
|
523
|
+
<p class="section-label">Streaming verification console</p>
|
|
524
|
+
<h3 id="chat-tester-title">Test this local Chat Adapter</h3>
|
|
525
|
+
<p>
|
|
526
|
+
Relmio sends this browser only to its same-origin local wizard.
|
|
527
|
+
The wizard then calls the literal loopback adapter address.
|
|
528
|
+
</p>
|
|
529
|
+
</div>
|
|
530
|
+
<button
|
|
531
|
+
id="chat-tester-reset"
|
|
532
|
+
class="button secondary"
|
|
533
|
+
type="button"
|
|
534
|
+
>
|
|
535
|
+
Forget tester
|
|
536
|
+
</button>
|
|
537
|
+
</div>
|
|
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
|
+
|
|
547
|
+
<form id="chat-tester-secure-form" class="chat-tester-form">
|
|
548
|
+
<fieldset>
|
|
549
|
+
<legend>Secure a temporary test session</legend>
|
|
550
|
+
<label class="field">
|
|
551
|
+
<span>Adapter base URL</span>
|
|
552
|
+
<input
|
|
553
|
+
id="chat-tester-endpoint"
|
|
554
|
+
type="url"
|
|
555
|
+
inputmode="url"
|
|
556
|
+
autocomplete="off"
|
|
557
|
+
spellcheck="false"
|
|
558
|
+
maxlength="64"
|
|
559
|
+
placeholder="http://127.0.0.1:14501"
|
|
560
|
+
pattern="http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/?"
|
|
561
|
+
required
|
|
562
|
+
/>
|
|
563
|
+
<small>Literal <code>http://127.0.0.1:PORT</code> only.</small>
|
|
564
|
+
</label>
|
|
565
|
+
<label class="field">
|
|
566
|
+
<span>Client credential</span>
|
|
567
|
+
<input
|
|
568
|
+
id="chat-tester-credential"
|
|
569
|
+
type="password"
|
|
570
|
+
autocomplete="off"
|
|
571
|
+
autocapitalize="none"
|
|
572
|
+
spellcheck="false"
|
|
573
|
+
maxlength="512"
|
|
574
|
+
required
|
|
575
|
+
/>
|
|
576
|
+
<small id="chat-tester-security-note">
|
|
577
|
+
The field is cleared as soon as you secure it. Encryption prevents accidental transit/storage exposure but not a compromised browser, extension, or local machine.
|
|
578
|
+
</small>
|
|
579
|
+
</label>
|
|
580
|
+
</fieldset>
|
|
581
|
+
<button id="chat-tester-secure" class="button primary" type="submit">
|
|
582
|
+
Secure credential for this test
|
|
583
|
+
</button>
|
|
584
|
+
</form>
|
|
585
|
+
|
|
586
|
+
<form id="chat-tester-message-form" class="chat-tester-message-form" hidden>
|
|
587
|
+
<label class="field" for="chat-tester-input">
|
|
588
|
+
<span>Message</span>
|
|
589
|
+
<textarea
|
|
590
|
+
id="chat-tester-input"
|
|
591
|
+
rows="3"
|
|
592
|
+
maxlength="8192"
|
|
593
|
+
spellcheck="true"
|
|
594
|
+
required
|
|
595
|
+
></textarea>
|
|
596
|
+
</label>
|
|
597
|
+
<button id="chat-tester-send" class="button primary" type="submit">
|
|
598
|
+
Send test message
|
|
599
|
+
</button>
|
|
600
|
+
</form>
|
|
601
|
+
|
|
602
|
+
<p id="chat-tester-status" class="chat-tester-status" role="status" aria-live="polite">
|
|
603
|
+
Secure the local client credential to begin.
|
|
604
|
+
</p>
|
|
605
|
+
<p id="chat-tester-error" class="chat-tester-error" role="alert" hidden></p>
|
|
606
|
+
<ol
|
|
607
|
+
id="chat-tester-transcript"
|
|
608
|
+
class="chat-tester-transcript"
|
|
609
|
+
role="log"
|
|
610
|
+
aria-live="polite"
|
|
611
|
+
aria-label="Local adapter test transcript"
|
|
612
|
+
></ol>
|
|
613
|
+
</section>
|
|
614
|
+
|
|
507
615
|
<aside id="client-warning" class="final-note"></aside>
|
|
508
616
|
|
|
509
617
|
<div class="actions">
|
package/src/ui/local.js
CHANGED
|
@@ -10,6 +10,14 @@ const state = {
|
|
|
10
10
|
planId: null,
|
|
11
11
|
plan: null,
|
|
12
12
|
installedTarget: null,
|
|
13
|
+
chatTester: {
|
|
14
|
+
conversationId: null,
|
|
15
|
+
encryptedCredential: null,
|
|
16
|
+
endpointBaseUrl: null,
|
|
17
|
+
expiresAt: null,
|
|
18
|
+
generation: 0,
|
|
19
|
+
keyId: null,
|
|
20
|
+
},
|
|
13
21
|
};
|
|
14
22
|
|
|
15
23
|
const messageBox = element("global-message");
|
|
@@ -31,11 +39,16 @@ function setMessage(text) {
|
|
|
31
39
|
|
|
32
40
|
function clearError() {
|
|
33
41
|
errorText.textContent = "";
|
|
42
|
+
element("local-image-build-troubleshooting").hidden = true;
|
|
34
43
|
errorBox.hidden = true;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
function showError(error) {
|
|
38
|
-
|
|
47
|
+
const localImageBuildFailed = error?.message === "Local image build failed.";
|
|
48
|
+
errorText.textContent = localImageBuildFailed
|
|
49
|
+
? "Relmio could not build the local image. Check that Docker is running, has enough disk space, and can pull its base image."
|
|
50
|
+
: error?.message ?? "Something went wrong.";
|
|
51
|
+
element("local-image-build-troubleshooting").hidden = !localImageBuildFailed;
|
|
39
52
|
errorBox.hidden = false;
|
|
40
53
|
errorBox.focus();
|
|
41
54
|
}
|
|
@@ -118,6 +131,123 @@ async function api(path, { method = "GET", body } = {}) {
|
|
|
118
131
|
return result;
|
|
119
132
|
}
|
|
120
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
|
+
|
|
121
251
|
function selectedTarget() {
|
|
122
252
|
return document.querySelector('input[name="target"]:checked')?.value;
|
|
123
253
|
}
|
|
@@ -269,6 +399,10 @@ function renderInstallResult(result) {
|
|
|
269
399
|
element("result-credential").textContent = result.clientCredential;
|
|
270
400
|
element("codex-production-warning").hidden = openAiApi;
|
|
271
401
|
element("codex-login").hidden = openAiApi;
|
|
402
|
+
element("chat-tester").hidden = !codexChat;
|
|
403
|
+
if (codexChat && !state.chatTester.keyId) {
|
|
404
|
+
element("chat-tester-endpoint").value = result.endpoint;
|
|
405
|
+
}
|
|
272
406
|
element("codex-production-warning-title").textContent = codexChat
|
|
273
407
|
? "Experimental Chat Adapter — trusted local backends or development servers only"
|
|
274
408
|
: "Experimental WebSocket transport";
|
|
@@ -300,6 +434,115 @@ function renderInstallResult(result) {
|
|
|
300
434
|
);
|
|
301
435
|
}
|
|
302
436
|
|
|
437
|
+
function setChatTesterStatus(text) {
|
|
438
|
+
element("chat-tester-status").textContent = text;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function clearChatTesterError() {
|
|
442
|
+
element("chat-tester-error").textContent = "";
|
|
443
|
+
element("chat-tester-error").hidden = true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function showChatTesterError(error) {
|
|
447
|
+
element("chat-tester-error").textContent =
|
|
448
|
+
error?.message ?? "The local adapter test could not be completed.";
|
|
449
|
+
element("chat-tester-error").hidden = false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function appendChatTesterTurn(kind, text) {
|
|
453
|
+
const item = document.createElement("li");
|
|
454
|
+
const heading = document.createElement("strong");
|
|
455
|
+
const content = document.createElement("p");
|
|
456
|
+
heading.textContent = kind === "user" ? "You" : "Local adapter";
|
|
457
|
+
content.textContent = text;
|
|
458
|
+
item.className = `chat-tester-turn chat-tester-turn-${kind}`;
|
|
459
|
+
item.append(heading, content);
|
|
460
|
+
element("chat-tester-transcript").append(item);
|
|
461
|
+
return content;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function clearChatTesterState() {
|
|
465
|
+
state.chatTester.conversationId = null;
|
|
466
|
+
state.chatTester.encryptedCredential = null;
|
|
467
|
+
state.chatTester.endpointBaseUrl = null;
|
|
468
|
+
state.chatTester.expiresAt = null;
|
|
469
|
+
state.chatTester.keyId = null;
|
|
470
|
+
state.chatTester.generation += 1;
|
|
471
|
+
element("chat-tester-credential").value = "";
|
|
472
|
+
element("chat-tester-endpoint").value = "";
|
|
473
|
+
element("chat-tester-input").value = "";
|
|
474
|
+
element("chat-tester-secure-form").hidden = false;
|
|
475
|
+
element("chat-tester-message-form").hidden = true;
|
|
476
|
+
element("chat-tester-transcript").replaceChildren();
|
|
477
|
+
clearChatTesterError();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function assertChatTesterKey(result) {
|
|
481
|
+
if (
|
|
482
|
+
!result ||
|
|
483
|
+
typeof result !== "object" ||
|
|
484
|
+
typeof result.keyId !== "string" ||
|
|
485
|
+
typeof result.expiresAt !== "string" ||
|
|
486
|
+
result.algorithm !== "RSA-OAEP-256" ||
|
|
487
|
+
!result.publicKeyJwk ||
|
|
488
|
+
result.publicKeyJwk.kty !== "RSA" ||
|
|
489
|
+
typeof result.publicKeyJwk.n !== "string" ||
|
|
490
|
+
typeof result.publicKeyJwk.e !== "string"
|
|
491
|
+
) {
|
|
492
|
+
throw new Error("The local tester returned an unexpected encryption key.");
|
|
493
|
+
}
|
|
494
|
+
return result;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function encryptChatTesterCredential(publicKeyJwk, clientCredential) {
|
|
498
|
+
if (!window.crypto?.subtle) {
|
|
499
|
+
throw new Error("This browser cannot create the required local test encryption key.");
|
|
500
|
+
}
|
|
501
|
+
const publicKey = await window.crypto.subtle.importKey(
|
|
502
|
+
"jwk",
|
|
503
|
+
publicKeyJwk,
|
|
504
|
+
{ name: "RSA-OAEP", hash: "SHA-256" },
|
|
505
|
+
false,
|
|
506
|
+
["encrypt"],
|
|
507
|
+
);
|
|
508
|
+
const encrypted = await window.crypto.subtle.encrypt(
|
|
509
|
+
{ name: "RSA-OAEP" },
|
|
510
|
+
publicKey,
|
|
511
|
+
new TextEncoder().encode(clientCredential),
|
|
512
|
+
);
|
|
513
|
+
const bytes = new Uint8Array(encrypted);
|
|
514
|
+
let binary = "";
|
|
515
|
+
for (const byte of bytes) {
|
|
516
|
+
binary += String.fromCodePoint(byte);
|
|
517
|
+
}
|
|
518
|
+
return window.btoa(binary);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function forgetChatTester({ announce = true } = {}) {
|
|
522
|
+
const keyId = state.chatTester.keyId;
|
|
523
|
+
clearChatTesterState();
|
|
524
|
+
if (!keyId) {
|
|
525
|
+
if (announce) {
|
|
526
|
+
setChatTesterStatus("The tester is cleared. Secure a credential to begin again.");
|
|
527
|
+
}
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
await api("/api/local/chat-test/reset", {
|
|
532
|
+
method: "POST",
|
|
533
|
+
body: { keyId },
|
|
534
|
+
});
|
|
535
|
+
if (announce) {
|
|
536
|
+
setChatTesterStatus("The tester key and transcript were forgotten.");
|
|
537
|
+
}
|
|
538
|
+
} catch (error) {
|
|
539
|
+
if (announce) {
|
|
540
|
+
showChatTesterError(error);
|
|
541
|
+
setChatTesterStatus("The browser tester was cleared. The local key will expire shortly.");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
303
546
|
function validateVerificationUrl(value) {
|
|
304
547
|
if (typeof value !== "string" || value.length > 2048) {
|
|
305
548
|
throw new Error("Relmio refused an unexpected sign-in destination.");
|
|
@@ -519,6 +762,9 @@ element("rotate-credential-button").addEventListener("click", async (event) => {
|
|
|
519
762
|
"Generating a replacement credential before activating it…",
|
|
520
763
|
);
|
|
521
764
|
try {
|
|
765
|
+
if (isCodexChat(state.installedTarget)) {
|
|
766
|
+
await forgetChatTester({ announce: false });
|
|
767
|
+
}
|
|
522
768
|
const staged = await api("/api/local/client-credential/rotate", {
|
|
523
769
|
method: "POST",
|
|
524
770
|
body: { target: state.installedTarget },
|
|
@@ -544,6 +790,154 @@ element("rotate-credential-button").addEventListener("click", async (event) => {
|
|
|
544
790
|
}
|
|
545
791
|
});
|
|
546
792
|
|
|
793
|
+
element("chat-tester-secure-form").addEventListener("submit", async (event) => {
|
|
794
|
+
event.preventDefault();
|
|
795
|
+
const form = event.currentTarget;
|
|
796
|
+
const button = element("chat-tester-secure");
|
|
797
|
+
const resetButton = element("chat-tester-reset");
|
|
798
|
+
const endpointInput = element("chat-tester-endpoint");
|
|
799
|
+
const clientCredentialInput = element("chat-tester-credential");
|
|
800
|
+
if (!form.reportValidity()) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
clearChatTesterError();
|
|
805
|
+
let clientCredential = clientCredentialInput.value;
|
|
806
|
+
clientCredentialInput.value = "";
|
|
807
|
+
let issuedKey;
|
|
808
|
+
resetButton.disabled = true;
|
|
809
|
+
setBusy(button, true, "Securing credential…");
|
|
810
|
+
setChatTesterStatus("Creating a short-lived local encryption key…");
|
|
811
|
+
try {
|
|
812
|
+
issuedKey = assertChatTesterKey(
|
|
813
|
+
await api("/api/local/chat-test/key", { method: "POST", body: {} }),
|
|
814
|
+
);
|
|
815
|
+
const encryptedCredential = await encryptChatTesterCredential(
|
|
816
|
+
issuedKey.publicKeyJwk,
|
|
817
|
+
clientCredential,
|
|
818
|
+
);
|
|
819
|
+
clientCredential = undefined;
|
|
820
|
+
state.chatTester.conversationId = null;
|
|
821
|
+
state.chatTester.encryptedCredential = encryptedCredential;
|
|
822
|
+
state.chatTester.endpointBaseUrl = endpointInput.value;
|
|
823
|
+
state.chatTester.expiresAt = issuedKey.expiresAt;
|
|
824
|
+
state.chatTester.keyId = issuedKey.keyId;
|
|
825
|
+
element("chat-tester-secure-form").hidden = true;
|
|
826
|
+
element("chat-tester-message-form").hidden = false;
|
|
827
|
+
setChatTesterStatus("Temporary test session secured. Send a message before the key expires.");
|
|
828
|
+
element("chat-tester-input").focus();
|
|
829
|
+
} catch (error) {
|
|
830
|
+
clientCredential = undefined;
|
|
831
|
+
if (issuedKey?.keyId) {
|
|
832
|
+
try {
|
|
833
|
+
await api("/api/local/chat-test/reset", {
|
|
834
|
+
method: "POST",
|
|
835
|
+
body: { keyId: issuedKey.keyId },
|
|
836
|
+
});
|
|
837
|
+
} catch {
|
|
838
|
+
// The only remaining server material is an expiring private key.
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
clearChatTesterState();
|
|
842
|
+
showChatTesterError(error);
|
|
843
|
+
setChatTesterStatus("The credential was cleared. Secure it again to retry.");
|
|
844
|
+
} finally {
|
|
845
|
+
clientCredential = undefined;
|
|
846
|
+
clientCredentialInput.value = "";
|
|
847
|
+
resetButton.disabled = false;
|
|
848
|
+
setBusy(button, false);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
element("chat-tester-message-form").addEventListener("submit", async (event) => {
|
|
853
|
+
event.preventDefault();
|
|
854
|
+
const form = event.currentTarget;
|
|
855
|
+
const button = element("chat-tester-send");
|
|
856
|
+
const resetButton = element("chat-tester-reset");
|
|
857
|
+
const input = element("chat-tester-input");
|
|
858
|
+
if (!form.reportValidity()) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (
|
|
862
|
+
!state.chatTester.keyId ||
|
|
863
|
+
!state.chatTester.encryptedCredential ||
|
|
864
|
+
!state.chatTester.endpointBaseUrl
|
|
865
|
+
) {
|
|
866
|
+
showChatTesterError(
|
|
867
|
+
new Error("This test credential has expired or was forgotten. Secure it again."),
|
|
868
|
+
);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
clearChatTesterError();
|
|
873
|
+
const text = input.value;
|
|
874
|
+
const generation = state.chatTester.generation;
|
|
875
|
+
input.value = "";
|
|
876
|
+
appendChatTesterTurn("user", text);
|
|
877
|
+
const assistantContent = appendChatTesterTurn("assistant", "");
|
|
878
|
+
resetButton.disabled = true;
|
|
879
|
+
setBusy(button, true, "Streaming response…");
|
|
880
|
+
setChatTesterStatus("Connecting to the loopback adapter through the secured local wizard…");
|
|
881
|
+
try {
|
|
882
|
+
const result = await streamChatTesterMessage(
|
|
883
|
+
{
|
|
884
|
+
endpointBaseUrl: state.chatTester.endpointBaseUrl,
|
|
885
|
+
keyId: state.chatTester.keyId,
|
|
886
|
+
encryptedCredential: state.chatTester.encryptedCredential,
|
|
887
|
+
input: text,
|
|
888
|
+
...(state.chatTester.conversationId
|
|
889
|
+
? { conversationId: state.chatTester.conversationId }
|
|
890
|
+
: {}),
|
|
891
|
+
},
|
|
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
|
+
);
|
|
902
|
+
if (state.chatTester.generation !== generation) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
state.chatTester.conversationId = result.conversationId;
|
|
906
|
+
if (!assistantContent.textContent) {
|
|
907
|
+
throw new Error("The local adapter completed without a visible response.");
|
|
908
|
+
}
|
|
909
|
+
setChatTesterStatus("Response received. Continue this conversation or forget the tester.");
|
|
910
|
+
} catch (error) {
|
|
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
|
+
}
|
|
922
|
+
showChatTesterError(error);
|
|
923
|
+
}
|
|
924
|
+
} finally {
|
|
925
|
+
resetButton.disabled = false;
|
|
926
|
+
setBusy(button, false);
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
element("chat-tester-reset").addEventListener("click", async (event) => {
|
|
931
|
+
const button = event.currentTarget;
|
|
932
|
+
clearChatTesterError();
|
|
933
|
+
setBusy(button, true, "Forgetting tester…");
|
|
934
|
+
try {
|
|
935
|
+
await forgetChatTester();
|
|
936
|
+
} finally {
|
|
937
|
+
setBusy(button, false);
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
547
941
|
element("codex-login-button").addEventListener("click", async (event) => {
|
|
548
942
|
const button = event.currentTarget;
|
|
549
943
|
const resultBox = element("device-code-result");
|