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/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='M7 8h19l31 24H41L26 20v10L7 17Z'/%3E%3Cpath fill='%2312211f' d='M7 47l19-13v10l15-12h16L26 56H7Z'/%3E%3C/svg%3E"
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="M7 8h19l31 24H41L26 20v10L7 17Z" />
32
- <path fill="#12211f" d="M7 47l19-13v10l15-12h16L26 56H7Z" />
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
- <h1 id="page-title">Run Relmio on localhost</h1>
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
- 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
  }
@@ -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");