relmio 0.6.0 → 0.7.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/src/ui/local.css CHANGED
@@ -365,6 +365,115 @@
365
365
  font-weight: 700;
366
366
  }
367
367
 
368
+ .chat-tester {
369
+ display: grid;
370
+ gap: 1rem;
371
+ margin-top: 1.25rem;
372
+ padding: 1rem;
373
+ border: 1px solid var(--accent-border);
374
+ border-radius: var(--radius);
375
+ background: var(--accent-soft);
376
+ }
377
+
378
+ .chat-tester-heading {
379
+ display: flex;
380
+ align-items: flex-start;
381
+ justify-content: space-between;
382
+ gap: 1rem;
383
+ }
384
+
385
+ .chat-tester-heading h3 {
386
+ margin: 0.2rem 0 0;
387
+ }
388
+
389
+ .chat-tester-heading p:not(.section-label) {
390
+ margin: 0.45rem 0 0;
391
+ color: var(--text-muted);
392
+ }
393
+
394
+ .chat-tester-heading .section-label {
395
+ margin: 0;
396
+ }
397
+
398
+ .chat-tester-form fieldset {
399
+ display: grid;
400
+ grid-template-columns: repeat(2, minmax(0, 1fr));
401
+ gap: 0.875rem;
402
+ margin: 0;
403
+ padding: 0;
404
+ border: 0;
405
+ }
406
+
407
+ .chat-tester-form legend {
408
+ grid-column: 1 / -1;
409
+ width: 100%;
410
+ margin-bottom: 0.5rem;
411
+ font-weight: 800;
412
+ }
413
+
414
+ .chat-tester-form .button,
415
+ .chat-tester-message-form .button {
416
+ margin-top: 0.875rem;
417
+ }
418
+
419
+ .chat-tester-status,
420
+ .chat-tester-error {
421
+ margin: 0;
422
+ padding: 0.75rem 0.875rem;
423
+ border-radius: var(--radius-sm);
424
+ font-size: 0.875rem;
425
+ overflow-wrap: anywhere;
426
+ }
427
+
428
+ .chat-tester-status {
429
+ border: 1px solid var(--accent-border);
430
+ background: var(--surface);
431
+ color: var(--text-muted);
432
+ }
433
+
434
+ .chat-tester-error {
435
+ border: 1px solid var(--warning-border);
436
+ background: var(--warning-soft);
437
+ color: var(--warning);
438
+ }
439
+
440
+ .chat-tester-transcript {
441
+ display: grid;
442
+ gap: 0.75rem;
443
+ max-height: 28rem;
444
+ margin: 0;
445
+ padding: 0;
446
+ overflow: auto;
447
+ list-style: none;
448
+ }
449
+
450
+ .chat-tester-turn {
451
+ max-width: min(100%, 46rem);
452
+ padding: 0.75rem 0.875rem;
453
+ border: 1px solid var(--border-strong);
454
+ border-radius: var(--radius-sm);
455
+ background: var(--surface);
456
+ }
457
+
458
+ .chat-tester-turn-assistant {
459
+ border-color: var(--accent-border);
460
+ background: var(--accent-soft);
461
+ }
462
+
463
+ .chat-tester-turn strong {
464
+ display: block;
465
+ margin-bottom: 0.25rem;
466
+ font-size: 0.75rem;
467
+ letter-spacing: 0.02em;
468
+ text-transform: uppercase;
469
+ }
470
+
471
+ .chat-tester-turn p {
472
+ margin: 0;
473
+ overflow-wrap: anywhere;
474
+ white-space: pre-wrap;
475
+ }
476
+
368
477
  .local-wizard a.button {
369
478
  text-decoration: none;
370
479
  }
@@ -379,10 +488,15 @@
379
488
  @media (max-width: 48rem) {
380
489
  .target-picker,
381
490
  .local-fields,
382
- .local-review-grid {
491
+ .local-review-grid,
492
+ .chat-tester-form fieldset {
383
493
  grid-template-columns: minmax(0, 1fr);
384
494
  }
385
495
 
496
+ .chat-tester-heading {
497
+ flex-direction: column;
498
+ }
499
+
386
500
  .review-details div {
387
501
  grid-template-columns: minmax(0, 1fr);
388
502
  gap: 0.2rem;
package/src/ui/local.html CHANGED
@@ -142,6 +142,13 @@
142
142
  hidden
143
143
  >
144
144
  <span id="global-error-text"></span>
145
+ <a
146
+ id="local-image-build-troubleshooting"
147
+ href="https://relmio.vercel.app/docs/troubleshooting#local-image-build-failed"
148
+ target="_blank"
149
+ rel="noopener noreferrer"
150
+ hidden
151
+ >View troubleshooting</a>
145
152
  </div>
146
153
  </div>
147
154
  </aside>
@@ -504,6 +511,98 @@
504
511
  </div>
505
512
  </section>
506
513
 
514
+ <section
515
+ id="chat-tester"
516
+ class="chat-tester"
517
+ aria-labelledby="chat-tester-title"
518
+ hidden
519
+ >
520
+ <div class="chat-tester-heading">
521
+ <div>
522
+ <p class="section-label">Local-only tester</p>
523
+ <h3 id="chat-tester-title">Test this local Chat Adapter</h3>
524
+ <p>
525
+ Relmio sends this browser only to its same-origin local wizard.
526
+ The wizard then calls the literal loopback adapter address.
527
+ </p>
528
+ </div>
529
+ <button
530
+ id="chat-tester-reset"
531
+ class="button secondary"
532
+ type="button"
533
+ >
534
+ Forget tester
535
+ </button>
536
+ </div>
537
+
538
+ <form id="chat-tester-secure-form" class="chat-tester-form">
539
+ <fieldset>
540
+ <legend>Secure a temporary test session</legend>
541
+ <label class="field">
542
+ <span>Adapter base URL</span>
543
+ <input
544
+ id="chat-tester-endpoint"
545
+ type="url"
546
+ inputmode="url"
547
+ autocomplete="off"
548
+ spellcheck="false"
549
+ maxlength="64"
550
+ placeholder="http://127.0.0.1:14501"
551
+ pattern="http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/?"
552
+ required
553
+ />
554
+ <small>Literal <code>http://127.0.0.1:PORT</code> only.</small>
555
+ </label>
556
+ <label class="field">
557
+ <span>Client credential</span>
558
+ <input
559
+ id="chat-tester-credential"
560
+ type="password"
561
+ autocomplete="off"
562
+ autocapitalize="none"
563
+ spellcheck="false"
564
+ maxlength="512"
565
+ required
566
+ />
567
+ <small id="chat-tester-security-note">
568
+ 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.
569
+ </small>
570
+ </label>
571
+ </fieldset>
572
+ <button id="chat-tester-secure" class="button primary" type="submit">
573
+ Secure credential for this test
574
+ </button>
575
+ </form>
576
+
577
+ <form id="chat-tester-message-form" class="chat-tester-message-form" hidden>
578
+ <label class="field" for="chat-tester-input">
579
+ <span>Message</span>
580
+ <textarea
581
+ id="chat-tester-input"
582
+ rows="3"
583
+ maxlength="8192"
584
+ spellcheck="true"
585
+ required
586
+ ></textarea>
587
+ </label>
588
+ <button id="chat-tester-send" class="button primary" type="submit">
589
+ Send test message
590
+ </button>
591
+ </form>
592
+
593
+ <p id="chat-tester-status" class="chat-tester-status" role="status" aria-live="polite">
594
+ Secure the local client credential to begin.
595
+ </p>
596
+ <p id="chat-tester-error" class="chat-tester-error" role="alert" hidden></p>
597
+ <ol
598
+ id="chat-tester-transcript"
599
+ class="chat-tester-transcript"
600
+ role="log"
601
+ aria-live="polite"
602
+ aria-label="Local adapter test transcript"
603
+ ></ol>
604
+ </section>
605
+
507
606
  <aside id="client-warning" class="final-note"></aside>
508
607
 
509
608
  <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
- errorText.textContent = error?.message ?? "Something went wrong.";
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
  }
@@ -269,6 +282,10 @@ function renderInstallResult(result) {
269
282
  element("result-credential").textContent = result.clientCredential;
270
283
  element("codex-production-warning").hidden = openAiApi;
271
284
  element("codex-login").hidden = openAiApi;
285
+ element("chat-tester").hidden = !codexChat;
286
+ if (codexChat && !state.chatTester.keyId) {
287
+ element("chat-tester-endpoint").value = result.endpoint;
288
+ }
272
289
  element("codex-production-warning-title").textContent = codexChat
273
290
  ? "Experimental Chat Adapter — trusted local backends or development servers only"
274
291
  : "Experimental WebSocket transport";
@@ -300,6 +317,114 @@ function renderInstallResult(result) {
300
317
  );
301
318
  }
302
319
 
320
+ function setChatTesterStatus(text) {
321
+ element("chat-tester-status").textContent = text;
322
+ }
323
+
324
+ function clearChatTesterError() {
325
+ element("chat-tester-error").textContent = "";
326
+ element("chat-tester-error").hidden = true;
327
+ }
328
+
329
+ function showChatTesterError(error) {
330
+ element("chat-tester-error").textContent =
331
+ error?.message ?? "The local adapter test could not be completed.";
332
+ element("chat-tester-error").hidden = false;
333
+ }
334
+
335
+ function appendChatTesterTurn(kind, text) {
336
+ const item = document.createElement("li");
337
+ const heading = document.createElement("strong");
338
+ const content = document.createElement("p");
339
+ heading.textContent = kind === "user" ? "You" : "Local adapter";
340
+ content.textContent = text;
341
+ item.className = `chat-tester-turn chat-tester-turn-${kind}`;
342
+ item.append(heading, content);
343
+ element("chat-tester-transcript").append(item);
344
+ }
345
+
346
+ function clearChatTesterState() {
347
+ state.chatTester.conversationId = null;
348
+ state.chatTester.encryptedCredential = null;
349
+ state.chatTester.endpointBaseUrl = null;
350
+ state.chatTester.expiresAt = null;
351
+ state.chatTester.keyId = null;
352
+ state.chatTester.generation += 1;
353
+ element("chat-tester-credential").value = "";
354
+ element("chat-tester-endpoint").value = "";
355
+ element("chat-tester-input").value = "";
356
+ element("chat-tester-secure-form").hidden = false;
357
+ element("chat-tester-message-form").hidden = true;
358
+ element("chat-tester-transcript").replaceChildren();
359
+ clearChatTesterError();
360
+ }
361
+
362
+ function assertChatTesterKey(result) {
363
+ if (
364
+ !result ||
365
+ typeof result !== "object" ||
366
+ typeof result.keyId !== "string" ||
367
+ typeof result.expiresAt !== "string" ||
368
+ result.algorithm !== "RSA-OAEP-256" ||
369
+ !result.publicKeyJwk ||
370
+ result.publicKeyJwk.kty !== "RSA" ||
371
+ typeof result.publicKeyJwk.n !== "string" ||
372
+ typeof result.publicKeyJwk.e !== "string"
373
+ ) {
374
+ throw new Error("The local tester returned an unexpected encryption key.");
375
+ }
376
+ return result;
377
+ }
378
+
379
+ async function encryptChatTesterCredential(publicKeyJwk, clientCredential) {
380
+ if (!window.crypto?.subtle) {
381
+ throw new Error("This browser cannot create the required local test encryption key.");
382
+ }
383
+ const publicKey = await window.crypto.subtle.importKey(
384
+ "jwk",
385
+ publicKeyJwk,
386
+ { name: "RSA-OAEP", hash: "SHA-256" },
387
+ false,
388
+ ["encrypt"],
389
+ );
390
+ const encrypted = await window.crypto.subtle.encrypt(
391
+ { name: "RSA-OAEP" },
392
+ publicKey,
393
+ new TextEncoder().encode(clientCredential),
394
+ );
395
+ const bytes = new Uint8Array(encrypted);
396
+ let binary = "";
397
+ for (const byte of bytes) {
398
+ binary += String.fromCodePoint(byte);
399
+ }
400
+ return window.btoa(binary);
401
+ }
402
+
403
+ async function forgetChatTester({ announce = true } = {}) {
404
+ const keyId = state.chatTester.keyId;
405
+ clearChatTesterState();
406
+ if (!keyId) {
407
+ if (announce) {
408
+ setChatTesterStatus("The tester is cleared. Secure a credential to begin again.");
409
+ }
410
+ return;
411
+ }
412
+ try {
413
+ await api("/api/local/chat-test/reset", {
414
+ method: "POST",
415
+ body: { keyId },
416
+ });
417
+ if (announce) {
418
+ setChatTesterStatus("The tester key and transcript were forgotten.");
419
+ }
420
+ } catch (error) {
421
+ if (announce) {
422
+ showChatTesterError(error);
423
+ setChatTesterStatus("The browser tester was cleared. The local key will expire shortly.");
424
+ }
425
+ }
426
+ }
427
+
303
428
  function validateVerificationUrl(value) {
304
429
  if (typeof value !== "string" || value.length > 2048) {
305
430
  throw new Error("Relmio refused an unexpected sign-in destination.");
@@ -519,6 +644,9 @@ element("rotate-credential-button").addEventListener("click", async (event) => {
519
644
  "Generating a replacement credential before activating it…",
520
645
  );
521
646
  try {
647
+ if (isCodexChat(state.installedTarget)) {
648
+ await forgetChatTester({ announce: false });
649
+ }
522
650
  const staged = await api("/api/local/client-credential/rotate", {
523
651
  method: "POST",
524
652
  body: { target: state.installedTarget },
@@ -544,6 +672,140 @@ element("rotate-credential-button").addEventListener("click", async (event) => {
544
672
  }
545
673
  });
546
674
 
675
+ element("chat-tester-secure-form").addEventListener("submit", async (event) => {
676
+ event.preventDefault();
677
+ const form = event.currentTarget;
678
+ const button = element("chat-tester-secure");
679
+ const resetButton = element("chat-tester-reset");
680
+ const endpointInput = element("chat-tester-endpoint");
681
+ const clientCredentialInput = element("chat-tester-credential");
682
+ if (!form.reportValidity()) {
683
+ return;
684
+ }
685
+
686
+ clearChatTesterError();
687
+ let clientCredential = clientCredentialInput.value;
688
+ clientCredentialInput.value = "";
689
+ let issuedKey;
690
+ resetButton.disabled = true;
691
+ setBusy(button, true, "Securing credential…");
692
+ setChatTesterStatus("Creating a short-lived local encryption key…");
693
+ try {
694
+ issuedKey = assertChatTesterKey(
695
+ await api("/api/local/chat-test/key", { method: "POST", body: {} }),
696
+ );
697
+ const encryptedCredential = await encryptChatTesterCredential(
698
+ issuedKey.publicKeyJwk,
699
+ clientCredential,
700
+ );
701
+ clientCredential = undefined;
702
+ state.chatTester.conversationId = null;
703
+ state.chatTester.encryptedCredential = encryptedCredential;
704
+ state.chatTester.endpointBaseUrl = endpointInput.value;
705
+ state.chatTester.expiresAt = issuedKey.expiresAt;
706
+ state.chatTester.keyId = issuedKey.keyId;
707
+ element("chat-tester-secure-form").hidden = true;
708
+ element("chat-tester-message-form").hidden = false;
709
+ setChatTesterStatus("Temporary test session secured. Send a message before the key expires.");
710
+ element("chat-tester-input").focus();
711
+ } catch (error) {
712
+ clientCredential = undefined;
713
+ if (issuedKey?.keyId) {
714
+ try {
715
+ await api("/api/local/chat-test/reset", {
716
+ method: "POST",
717
+ body: { keyId: issuedKey.keyId },
718
+ });
719
+ } catch {
720
+ // The only remaining server material is an expiring private key.
721
+ }
722
+ }
723
+ clearChatTesterState();
724
+ showChatTesterError(error);
725
+ setChatTesterStatus("The credential was cleared. Secure it again to retry.");
726
+ } finally {
727
+ clientCredential = undefined;
728
+ clientCredentialInput.value = "";
729
+ resetButton.disabled = false;
730
+ setBusy(button, false);
731
+ }
732
+ });
733
+
734
+ element("chat-tester-message-form").addEventListener("submit", async (event) => {
735
+ event.preventDefault();
736
+ const form = event.currentTarget;
737
+ const button = element("chat-tester-send");
738
+ const resetButton = element("chat-tester-reset");
739
+ const input = element("chat-tester-input");
740
+ if (!form.reportValidity()) {
741
+ return;
742
+ }
743
+ if (
744
+ !state.chatTester.keyId ||
745
+ !state.chatTester.encryptedCredential ||
746
+ !state.chatTester.endpointBaseUrl
747
+ ) {
748
+ showChatTesterError(
749
+ new Error("This test credential has expired or was forgotten. Secure it again."),
750
+ );
751
+ return;
752
+ }
753
+
754
+ clearChatTesterError();
755
+ const text = input.value;
756
+ const generation = state.chatTester.generation;
757
+ input.value = "";
758
+ appendChatTesterTurn("user", text);
759
+ resetButton.disabled = true;
760
+ setBusy(button, true, "Waiting for adapter…");
761
+ setChatTesterStatus("The local wizard is testing the adapter without exposing its credential to the page.");
762
+ try {
763
+ const result = await api("/api/local/chat-test/message", {
764
+ method: "POST",
765
+ body: {
766
+ endpointBaseUrl: state.chatTester.endpointBaseUrl,
767
+ keyId: state.chatTester.keyId,
768
+ encryptedCredential: state.chatTester.encryptedCredential,
769
+ input: text,
770
+ ...(state.chatTester.conversationId
771
+ ? { conversationId: state.chatTester.conversationId }
772
+ : {}),
773
+ },
774
+ });
775
+ if (
776
+ typeof result.conversationId !== "string" ||
777
+ typeof result.output !== "string"
778
+ ) {
779
+ throw new Error("The local adapter returned an unexpected response.");
780
+ }
781
+ if (state.chatTester.generation !== generation) {
782
+ return;
783
+ }
784
+ state.chatTester.conversationId = result.conversationId;
785
+ appendChatTesterTurn("assistant", result.output);
786
+ setChatTesterStatus("Response received. Continue this conversation or forget the tester.");
787
+ } catch (error) {
788
+ if (state.chatTester.generation === generation) {
789
+ showChatTesterError(error);
790
+ setChatTesterStatus("No adapter response was added. You can retry or forget this tester.");
791
+ }
792
+ } finally {
793
+ resetButton.disabled = false;
794
+ setBusy(button, false);
795
+ }
796
+ });
797
+
798
+ element("chat-tester-reset").addEventListener("click", async (event) => {
799
+ const button = event.currentTarget;
800
+ clearChatTesterError();
801
+ setBusy(button, true, "Forgetting tester…");
802
+ try {
803
+ await forgetChatTester();
804
+ } finally {
805
+ setBusy(button, false);
806
+ }
807
+ });
808
+
547
809
  element("codex-login-button").addEventListener("click", async (event) => {
548
810
  const button = event.currentTarget;
549
811
  const resultBox = element("device-code-result");