1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Room Chat</title>
7 <style>
8 *,
9 *::before,
10 *::after {
11 box-sizing: border-box;
12 margin: 0;
13 padding: 0;
14 }
15 body {
16 font-family:
17 system-ui,
18 -apple-system,
19 sans-serif;
20 background: #0f172a;
21 color: #e2e8f0;
22 height: 100vh;
23 display: flex;
24 flex-direction: column;
25 align-items: center;
26 justify-content: center;
27 padding: 1rem;
28 }
29 .container {
30 width: 100%;
31 max-width: 560px;
32 display: flex;
33 flex-direction: column;
34 gap: 0.75rem;
35 height: 90vh;
36 }
37 h1 {
38 font-size: 1.25rem;
39 font-weight: 600;
40 color: #94a3b8;
41 }
42 #status {
43 font-size: 0.8rem;
44 padding: 0.35rem 0.75rem;
45 border-radius: 999px;
46 display: inline-block;
47 align-self: flex-start;
48 }
49 #status.connected {
50 background: #065f46;
51 color: #6ee7b7;
52 }
53 #status.disconnected {
54 background: #7f1d1d;
55 color: #fca5a5;
56 }
57 #status.connecting {
58 background: #78350f;
59 color: #fcd34d;
60 }
61 #messages {
62 flex: 1;
63 overflow-y: auto;
64 background: #1e293b;
65 border-radius: 0.5rem;
66 padding: 0.75rem;
67 display: flex;
68 flex-direction: column;
69 gap: 0.35rem;
70 font-size: 0.9rem;
71 font-family: "SF Mono", "Fira Code", monospace;
72 }
73 .msg {
74 padding: 0.25rem 0;
75 border-bottom: 1px solid #334155;
76 word-break: break-word;
77 }
78 .msg.system {
79 color: #64748b;
80 font-style: italic;
81 }
82 #form {
83 display: flex;
84 gap: 0.5rem;
85 }
86 #input {
87 flex: 1;
88 padding: 0.6rem 0.75rem;
89 border-radius: 0.375rem;
90 border: 1px solid #334155;
91 background: #1e293b;
92 color: #e2e8f0;
93 font-size: 0.9rem;
94 outline: none;
95 }
96 #input:focus {
97 border-color: #3b82f6;
98 }
99 button {
100 padding: 0.6rem 1.25rem;
101 border-radius: 0.375rem;
102 border: none;
103 background: #3b82f6;
104 color: #fff;
105 font-weight: 600;
106 cursor: pointer;
107 font-size: 0.9rem;
108 }
109 button:hover {
110 background: #2563eb;
111 }
112 button:disabled {
113 opacity: 0.4;
114 cursor: not-allowed;
115 }
116 .header {
117 display: flex;
118 align-items: center;
119 justify-content: space-between;
120 }
121 .header-right {
122 display: flex;
123 align-items: center;
124 gap: 0.5rem;
125 }
126 #avatar-btn {
127 width: 2.25rem;
128 height: 2.25rem;
129 border-radius: 50%;
130 background: #334155;
131 border: 2px solid #475569;
132 color: #94a3b8;
133 font-size: 1rem;
134 cursor: pointer;
135 display: flex;
136 align-items: center;
137 justify-content: center;
138 padding: 0;
139 transition:
140 border-color 0.15s,
141 background 0.15s;
142 position: relative;
143 }
144 #avatar-btn:hover {
145 border-color: #3b82f6;
146 background: #1e293b;
147 }
148 #avatar-btn.active {
149 border-color: #3b82f6;
150 background: #1e293b;
151 }
152 #avatar-btn svg {
153 width: 1.1rem;
154 height: 1.1rem;
155 fill: currentColor;
156 }
157 #toggle {
158 background: #dc2626;
159 font-size: 0.8rem;
160 padding: 0.35rem 0.75rem;
161 }
162 #toggle:hover {
163 background: #b91c1c;
164 }
165 #toggle.off {
166 background: #16a34a;
167 }
168 #toggle.off:hover {
169 background: #15803d;
170 }
171 #actions {
172 display: flex;
173 gap: 0.5rem;
174 }
175 #increment {
176 background: #7c3aed;
177 }
178 #increment:hover {
179 background: #6d28d9;
180 }
181 #increment:disabled {
182 opacity: 0.4;
183 cursor: not-allowed;
184 }
185 #workflow-run {
186 background: #0ea5e9;
187 }
188 #workflow-run:hover {
189 background: #0284c7;
190 }
191 #workflow-run:disabled {
192 opacity: 0.4;
193 cursor: not-allowed;
194 }
195 #workflow-panel {
196 display: flex;
197 flex-direction: column;
198 gap: 0.5rem;
199 padding: 0.75rem;
200 background: #1e293b;
201 border-radius: 0.5rem;
202 border: 1px solid #334155;
203 }
204 #workflow-panel h2 {
205 font-size: 0.75rem;
206 font-weight: 600;
207 color: #94a3b8;
208 text-transform: uppercase;
209 letter-spacing: 0.04em;
210 }
211 #workflow-status {
212 font-size: 0.75rem;
213 font-family: "SF Mono", "Fira Code", monospace;
214 color: #cbd5e1;
215 white-space: pre-wrap;
216 word-break: break-word;
217 min-height: 2.5rem;
218 line-height: 1.4;
219 }
220 #artifacts-panel {
221 display: flex;
222 flex-direction: column;
223 gap: 0.5rem;
224 padding: 0.75rem;
225 background: #1e293b;
226 border-radius: 0.5rem;
227 border: 1px solid #334155;
228 }
229 #artifacts-panel h2 {
230 font-size: 0.75rem;
231 font-weight: 600;
232 color: #94a3b8;
233 text-transform: uppercase;
234 letter-spacing: 0.04em;
235 }
236 #artifacts-status {
237 font-size: 0.75rem;
238 font-family: "SF Mono", "Fira Code", monospace;
239 color: #cbd5e1;
240 white-space: pre-wrap;
241 word-break: break-word;
242 min-height: 2.5rem;
243 line-height: 1.4;
244 }
245 #ai-panel {
246 display: flex;
247 flex-direction: column;
248 gap: 0.5rem;
249 padding: 0.75rem;
250 background: #1e293b;
251 border-radius: 0.5rem;
252 border: 1px solid #334155;
253 }
254 #ai-panel h2 {
255 font-size: 0.75rem;
256 font-weight: 600;
257 color: #94a3b8;
258 text-transform: uppercase;
259 letter-spacing: 0.04em;
260 }
261 #ai-status {
262 font-size: 0.75rem;
263 font-family: "SF Mono", "Fira Code", monospace;
264 color: #cbd5e1;
265 white-space: pre-wrap;
266 word-break: break-word;
267 min-height: 2.5rem;
268 line-height: 1.4;
269 }
270 #auth-panel {
271 display: none;
272 flex-direction: column;
273 gap: 0.75rem;
274 padding: 0.75rem;
275 background: #1e293b;
276 border-radius: 0.5rem;
277 border: 1px solid #334155;
278 }
279 #auth-panel.visible {
280 display: flex;
281 }
282 #auth-panel h2 {
283 font-size: 0.75rem;
284 font-weight: 600;
285 color: #94a3b8;
286 text-transform: uppercase;
287 letter-spacing: 0.04em;
288 }
289 #auth-fields {
290 display: grid;
291 grid-template-columns: repeat(2, minmax(0, 1fr));
292 gap: 0.5rem;
293 }
294 #auth-fields input {
295 width: 100%;
296 padding: 0.6rem 0.75rem;
297 border-radius: 0.375rem;
298 border: 1px solid #334155;
299 background: #0f172a;
300 color: #e2e8f0;
301 font-size: 0.9rem;
302 outline: none;
303 }
304 #auth-fields input:focus {
305 border-color: #3b82f6;
306 }
307 #auth-email {
308 grid-column: 1 / -1;
309 }
310 #auth-name {
311 grid-column: 1 / -1;
312 }
313 #auth-actions {
314 display: flex;
315 gap: 0.5rem;
316 }
317 #auth-signup {
318 background: #2563eb;
319 }
320 #auth-signin {
321 background: #059669;
322 }
323 #auth-signin:hover {
324 background: #047857;
325 }
326 #auth-status {
327 font-size: 0.75rem;
328 font-family: "SF Mono", "Fira Code", monospace;
329 color: #cbd5e1;
330 white-space: pre-wrap;
331 word-break: break-word;
332 min-height: 2.5rem;
333 line-height: 1.4;
334 }
335 @media (max-width: 640px) {
336 #auth-fields {
337 grid-template-columns: 1fr;
338 }
339 }
340 </style>
341 </head>
342 <body>
343 <div class="container">
344 <div class="header">
345 <h1>Room Chat</h1>
346 <div class="header-right">
347 <button
348 id="clear-chat"
349 title="Clear chat"
350 style="
351 background: #475569;
352 font-size: 0.8rem;
353 padding: 0.35rem 0.75rem;
354 "
355 >
356 Clear
357 </button>
358 <button id="toggle">Disconnect</button>
359 <button id="avatar-btn" title="Account">
360 <svg viewBox="0 0 24 24">
361 <path
362 d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"
363 />
364 </svg>
365 </button>
366 </div>
367 </div>
368 <span id="status" class="disconnected">disconnected</span>
369 <div id="auth-panel">
370 <h2>Auth</h2>
371 <div id="auth-fields">
372 <input
373 id="auth-email"
374 type="email"
375 placeholder="Email"
376 autocomplete="email"
377 />
378 <input
379 id="auth-password"
380 type="password"
381 placeholder="Password"
382 autocomplete="current-password"
383 />
384 <input
385 id="auth-name"
386 type="text"
387 placeholder="Display name"
388 autocomplete="name"
389 />
390 </div>
391 <div id="auth-actions">
392 <button id="auth-signup" type="button">Sign Up</button>
393 <button id="auth-signin" type="button">Sign In</button>
394 </div>
395 <div id="auth-status">Idle.</div>
396 </div>
397 <div id="messages"></div>
398 <div id="actions">
399 <button id="increment">Increment Counter</button>
400 </div>
401 <div id="reminder-panel">
402 <h2>Scheduled Reminders</h2>
403 <div style="display: flex; gap: 0.5rem">
404 <input
405 id="reminder-delay"
406 type="number"
407 min="1"
408 value="5"
409 style="
410 width: 4rem;
411 padding: 0.6rem 0.75rem;
412 border-radius: 0.375rem;
413 border: 1px solid #334155;
414 background: #0f172a;
415 color: #e2e8f0;
416 font-size: 0.9rem;
417 outline: none;
418 "
419 />
420 <span style="align-self: center; color: #94a3b8; font-size: 0.8rem"
421 >seconds</span
422 >
423 <input
424 id="reminder-msg"
425 type="text"
426 placeholder="Reminder message…"
427 style="
428 flex: 1;
429 padding: 0.6rem 0.75rem;
430 border-radius: 0.375rem;
431 border: 1px solid #334155;
432 background: #0f172a;
433 color: #e2e8f0;
434 font-size: 0.9rem;
435 outline: none;
436 "
437 />
438 <button
439 id="reminder-send"
440 type="button"
441 style="background: #f59e0b; padding: 0.6rem 1rem"
442 >
443 Schedule
444 </button>
445 </div>
446 <div
447 id="reminder-status"
448 style="
449 font-size: 0.75rem;
450 font-family: &quot;SF Mono&quot;, &quot;Fira Code&quot;, monospace;
451 color: #cbd5e1;
452 min-height: 1.2rem;
453 line-height: 1.4;
454 "
455 >
456 Type /remind &lt;seconds&gt; &lt;message&gt; in chat, or use the form
457 above.
458 </div>
459 </div>
460 <div id="artifacts-panel">
461 <h2>Artifacts (Git for Agents)</h2>
462 <div style="display: flex; gap: 0.5rem; flex-wrap: wrap">
463 <input
464 id="artifacts-name"
465 type="text"
466 placeholder="repo name (e.g. starter-1234)"
467 style="
468 flex: 1;
469 min-width: 12rem;
470 padding: 0.6rem 0.75rem;
471 border-radius: 0.375rem;
472 border: 1px solid #334155;
473 background: #0f172a;
474 color: #e2e8f0;
475 font-size: 0.9rem;
476 outline: none;
477 "
478 />
479 <button
480 id="artifacts-create"
481 type="button"
482 style="background: #10b981"
483 >
484 Create
485 </button>
486 <button id="artifacts-info" type="button" style="background: #6366f1">
487 Info
488 </button>
489 <button
490 id="artifacts-token"
491 type="button"
492 style="background: #eab308; color: #1f2937"
493 >
494 New token
495 </button>
496 <button id="artifacts-list" type="button" style="background: #475569">
497 List
498 </button>
499 </div>
500 <div id="artifacts-status">
501 Create a repo, then mint scoped tokens for git clone/push.
502 </div>
503 </div>
504 <div id="ai-panel">
505 <h2>AI Gateway</h2>
506 <div style="display: flex; gap: 0.5rem; flex-wrap: wrap">
507 <input
508 id="ai-prompt"
509 type="text"
510 placeholder="Ask the model anything…"
511 style="
512 flex: 1;
513 min-width: 12rem;
514 padding: 0.6rem 0.75rem;
515 border-radius: 0.375rem;
516 border: 1px solid #334155;
517 background: #0f172a;
518 color: #e2e8f0;
519 font-size: 0.9rem;
520 outline: none;
521 "
522 />
523 <button id="ai-send" type="button" style="background: #f43f5e">
524 Ask
525 </button>
526 </div>
527 <div id="ai-status">
528 Routes Workers AI calls through Cloudflare AI Gateway for caching,
529 rate limiting and observability.
530 </div>
531 </div>
532 <div id="workflow-panel">
533 <h2>NotifyWorkflow</h2>
534 <button id="workflow-run" type="button">
535 Run workflow (broadcasts to this room)
536 </button>
537 <div id="workflow-status">
538 Idle — messages will appear in the chat above.
539 </div>
540 </div>
541 <form id="form">
542 <input
543 id="input"
544 type="text"
545 placeholder="Type a message…"
546 autocomplete="off"
547 />
548 <button type="submit">Send</button>
549 </form>
550 </div>
551
552 <script>
553 const proto = location.protocol === "https:" ? "wss:" : "ws:";
554 const BASE = `${proto}//${location.host}`;
555 const roomId =
556 new URLSearchParams(location.search).get("room") || "default";
557
558 const statusEl = document.getElementById("status");
559 const messagesEl = document.getElementById("messages");
560 const form = document.getElementById("form");
561 const input = document.getElementById("input");
562 const authEmailEl = document.getElementById("auth-email");
563 const authPasswordEl = document.getElementById("auth-password");
564 const authNameEl = document.getElementById("auth-name");
565 const authSignUpBtn = document.getElementById("auth-signup");
566 const authSignInBtn = document.getElementById("auth-signin");
567 const authStatusEl = document.getElementById("auth-status");
568
569 function setStatus(s) {
570 statusEl.textContent = s;
571 statusEl.className = s;
572 }
573
574 function addMessage(text, system = false) {
575 const div = document.createElement("div");
576 div.className = "msg" + (system ? " system" : "");
577 div.textContent = text;
578 messagesEl.appendChild(div);
579 messagesEl.scrollTop = messagesEl.scrollHeight;
580 }
581
582 function setAuthStatus(text) {
583 authStatusEl.textContent = text;
584 }
585
586 async function readResponseBody(response) {
587 const text = await response.text();
588 try {
589 return JSON.stringify(JSON.parse(text), null, 2);
590 } catch {
591 return text || "(empty)";
592 }
593 }
594
595 function getAuthPayload() {
596 return {
597 email: authEmailEl.value.trim(),
598 password: authPasswordEl.value,
599 name: authNameEl.value.trim() || "Room Chat User",
600 };
601 }
602
603 async function submitAuth(path, body, buttonLabel) {
604 authSignUpBtn.disabled = true;
605 authSignInBtn.disabled = true;
606 setAuthStatus(`${buttonLabel}…`);
607 try {
608 const response = await fetch(path, {
609 method: "POST",
610 headers: {
611 "content-type": "application/json",
612 },
613 credentials: "include",
614 body: JSON.stringify(body),
615 });
616 const payload = await readResponseBody(response);
617 setAuthStatus(`status: ${response.status}\n${payload}`);
618 addMessage(`${buttonLabel} -> HTTP ${response.status}`, true);
619 } catch (error) {
620 const message =
621 error instanceof Error ? error.message : String(error);
622 setAuthStatus(`request failed: ${message}`);
623 addMessage(`${buttonLabel} failed: ${message}`, true);
624 } finally {
625 authSignUpBtn.disabled = false;
626 authSignInBtn.disabled = false;
627 }
628 }
629
630 authSignUpBtn.addEventListener("click", async () => {
631 const { email, password, name } = getAuthPayload();
632 if (!email || !password) {
633 setAuthStatus("Email and password are required.");
634 return;
635 }
636 await submitAuth(
637 "/auth/sign-up/email",
638 { email, password, name },
639 "Sign Up",
640 );
641 });
642
643 authSignInBtn.addEventListener("click", async () => {
644 const { email, password } = getAuthPayload();
645 if (!email || !password) {
646 setAuthStatus("Email and password are required.");
647 return;
648 }
649 await submitAuth("/auth/sign-in/email", { email, password }, "Sign In");
650 });
651
652 document.getElementById("clear-chat").addEventListener("click", () => {
653 messagesEl.innerHTML = "";
654 });
655
656 const avatarBtn = document.getElementById("avatar-btn");
657 const authPanel = document.getElementById("auth-panel");
658 avatarBtn.addEventListener("click", () => {
659 authPanel.classList.toggle("visible");
660 avatarBtn.classList.toggle("active");
661 });
662
663 const toggleBtn = document.getElementById("toggle");
664 let ws;
665 let autoReconnect = true;
666 let reconnectTimer;
667
668 function connect() {
669 setStatus("connecting");
670 addMessage(`Connecting to room "${roomId}"…`, true);
671
672 ws = new WebSocket(`${BASE}/room/${roomId}`);
673
674 ws.addEventListener("open", () => {
675 setStatus("connected");
676 addMessage("Connected.", true);
677 });
678
679 ws.addEventListener("message", (e) => {
680 addMessage(e.data);
681 });
682
683 ws.addEventListener("close", (e) => {
684 setStatus("disconnected");
685 addMessage(`Disconnected (code ${e.code}).`, true);
686 if (autoReconnect) {
687 reconnectTimer = setTimeout(connect, 2000);
688 }
689 });
690
691 ws.addEventListener("error", () => {
692 setStatus("disconnected");
693 addMessage("WebSocket error.", true);
694 });
695 }
696
697 toggleBtn.addEventListener("click", () => {
698 if (autoReconnect) {
699 autoReconnect = false;
700 toggleBtn.textContent = "Connect";
701 toggleBtn.className = "off";
702 clearTimeout(reconnectTimer);
703 if (ws && ws.readyState === WebSocket.OPEN) {
704 ws.close();
705 }
706 } else {
707 autoReconnect = true;
708 toggleBtn.textContent = "Disconnect";
709 toggleBtn.className = "";
710 if (!ws || ws.readyState !== WebSocket.OPEN) {
711 connect();
712 }
713 }
714 });
715
716 const incrementBtn = document.getElementById("increment");
717 incrementBtn.addEventListener("click", async () => {
718 incrementBtn.disabled = true;
719 incrementBtn.textContent = "Incrementing…";
720 try {
721 const resp = await fetch("/sandbox/increment", { method: "POST" });
722 const data = await resp.json();
723 addMessage(`Counter incremented to ${data.counter}`, true);
724 } catch (err) {
725 addMessage(`Increment failed: ${err.message}`, true);
726 } finally {
727 incrementBtn.disabled = false;
728 incrementBtn.textContent = "Increment Counter";
729 }
730 });
731
732 const workflowRunBtn = document.getElementById("workflow-run");
733 const workflowStatusEl = document.getElementById("workflow-status");
734
735 function isWorkflowTerminal(status) {
736 if (!status || typeof status.status !== "string") return false;
737 const s = status.status.toLowerCase();
738 return (
739 s === "complete" ||
740 s === "completed" ||
741 s === "errored" ||
742 s === "failed" ||
743 s === "terminated" ||
744 s === "canceled" ||
745 s === "cancelled" ||
746 (status.error != null && status.error !== undefined)
747 );
748 }
749
750 function formatWorkflowStatus(status) {
751 const lines = [
752 `status: ${status.status ?? "(none)"}`,
753 status.output !== undefined
754 ? `output: ${JSON.stringify(status.output)}`
755 : null,
756 status.error ? `error: ${JSON.stringify(status.error)}` : null,
757 ].filter(Boolean);
758 return lines.join("\n");
759 }
760
761 let workflowPollTimer = null;
762
763 workflowRunBtn.addEventListener("click", async () => {
764 if (workflowPollTimer) {
765 clearTimeout(workflowPollTimer);
766 workflowPollTimer = null;
767 }
768 workflowRunBtn.disabled = true;
769 workflowStatusEl.textContent = "Starting workflow…";
770 addMessage(
771 `Starting NotifyWorkflow for room "${roomId}" (watch for [workflow] lines)…`,
772 true,
773 );
774 try {
775 const startResp = await fetch(
776 `/workflow/start/${encodeURIComponent(roomId)}`,
777 { method: "POST" },
778 );
779 if (!startResp.ok) {
780 const errText = await startResp.text();
781 throw new Error(errText || `HTTP ${startResp.status}`);
782 }
783 const { instanceId } = await startResp.json();
784 if (!instanceId) {
785 throw new Error("No instanceId in response");
786 }
787 workflowStatusEl.textContent = `instance: ${instanceId}\npolling…`;
788
789 let pollCount = 0;
790 const maxPolls = 120;
791
792 const poll = async () => {
793 try {
794 pollCount += 1;
795 if (pollCount > maxPolls) {
796 workflowStatusEl.textContent = `instance: ${instanceId}\n(stopped polling after ${maxPolls} attempts — check logs)`;
797 workflowRunBtn.disabled = false;
798 addMessage("Workflow status polling timed out.", true);
799 return;
800 }
801 const stResp = await fetch(
802 `/workflow/status/${encodeURIComponent(instanceId)}`,
803 );
804 if (!stResp.ok) {
805 workflowStatusEl.textContent = `status fetch failed: HTTP ${stResp.status}`;
806 workflowRunBtn.disabled = false;
807 return;
808 }
809 const st = await stResp.json();
810 workflowStatusEl.textContent =
811 `instance: ${instanceId}\n` + formatWorkflowStatus(st);
812 if (isWorkflowTerminal(st)) {
813 workflowRunBtn.disabled = false;
814 addMessage(
815 `Workflow finished: ${st.status}` +
816 (st.output !== undefined
817 ? ` — ${JSON.stringify(st.output)}`
818 : ""),
819 true,
820 );
821 return;
822 }
823 workflowPollTimer = setTimeout(poll, 600);
824 } catch (e) {
825 workflowStatusEl.textContent = `poll error: ${e.message}`;
826 workflowRunBtn.disabled = false;
827 }
828 };
829 await poll();
830 } catch (err) {
831 workflowStatusEl.textContent = `Error: ${err.message}`;
832 addMessage(`Workflow start failed: ${err.message}`, true);
833 workflowRunBtn.disabled = false;
834 }
835 });
836
837 const artifactsNameEl = document.getElementById("artifacts-name");
838 const artifactsStatusEl = document.getElementById("artifacts-status");
839 const artifactsCreateBtn = document.getElementById("artifacts-create");
840 const artifactsInfoBtn = document.getElementById("artifacts-info");
841 const artifactsTokenBtn = document.getElementById("artifacts-token");
842 const artifactsListBtn = document.getElementById("artifacts-list");
843
844 function setArtifactsStatus(label, payload) {
845 const body =
846 typeof payload === "string"
847 ? payload
848 : JSON.stringify(payload, null, 2);
849 artifactsStatusEl.textContent = `${label}\n${body}`;
850 }
851
852 async function callArtifacts(method, path, body) {
853 const init = { method };
854 if (body !== undefined) {
855 init.headers = { "content-type": "application/json" };
856 init.body = JSON.stringify(body);
857 }
858 const resp = await fetch(path, init);
859 const text = await resp.text();
860 let parsed;
861 try {
862 parsed = JSON.parse(text);
863 } catch {
864 parsed = text;
865 }
866 return { status: resp.status, body: parsed };
867 }
868
869 function getRepoName() {
870 const name = artifactsNameEl.value.trim();
871 if (!name) {
872 setArtifactsStatus("error", "repo name is required");
873 return null;
874 }
875 return name;
876 }
877
878 artifactsCreateBtn.addEventListener("click", async () => {
879 const name = getRepoName();
880 if (!name) return;
881 artifactsCreateBtn.disabled = true;
882 setArtifactsStatus(`POST /repos/create ${name}`, "...");
883 try {
884 const { status, body } = await callArtifacts(
885 "POST",
886 "/repos/create",
887 { name, description: "Created from Room Chat" },
888 );
889 setArtifactsStatus(`HTTP ${status}`, body);
890 addMessage(
891 `Artifacts: created repo "${name}" -> HTTP ${status}`,
892 true,
893 );
894 } catch (e) {
895 setArtifactsStatus("request failed", e.message);
896 } finally {
897 artifactsCreateBtn.disabled = false;
898 }
899 });
900
901 artifactsInfoBtn.addEventListener("click", async () => {
902 const name = getRepoName();
903 if (!name) return;
904 artifactsInfoBtn.disabled = true;
905 setArtifactsStatus(`GET /repos/info?name=${name}`, "...");
906 try {
907 const { status, body } = await callArtifacts(
908 "GET",
909 `/repos/info?name=${encodeURIComponent(name)}`,
910 );
911 setArtifactsStatus(`HTTP ${status}`, body);
912 } catch (e) {
913 setArtifactsStatus("request failed", e.message);
914 } finally {
915 artifactsInfoBtn.disabled = false;
916 }
917 });
918
919 artifactsTokenBtn.addEventListener("click", async () => {
920 const name = getRepoName();
921 if (!name) return;
922 artifactsTokenBtn.disabled = true;
923 setArtifactsStatus(`POST /repos/token ${name}`, "...");
924 try {
925 const { status, body } = await callArtifacts("POST", "/repos/token", {
926 name,
927 scope: "write",
928 ttl: 3600,
929 });
930 setArtifactsStatus(`HTTP ${status}`, body);
931 } catch (e) {
932 setArtifactsStatus("request failed", e.message);
933 } finally {
934 artifactsTokenBtn.disabled = false;
935 }
936 });
937
938 artifactsListBtn.addEventListener("click", async () => {
939 artifactsListBtn.disabled = true;
940 setArtifactsStatus("GET /repos/list", "...");
941 try {
942 const { status, body } = await callArtifacts("GET", "/repos/list");
943 setArtifactsStatus(`HTTP ${status}`, body);
944 } catch (e) {
945 setArtifactsStatus("request failed", e.message);
946 } finally {
947 artifactsListBtn.disabled = false;
948 }
949 });
950
951 const aiPromptEl = document.getElementById("ai-prompt");
952 const aiSendBtn = document.getElementById("ai-send");
953 const aiStatusEl = document.getElementById("ai-status");
954
955 aiSendBtn.addEventListener("click", async () => {
956 const prompt = aiPromptEl.value.trim();
957 if (!prompt) {
958 aiStatusEl.textContent = "Enter a prompt first.";
959 return;
960 }
961 aiSendBtn.disabled = true;
962 aiStatusEl.textContent = `> ${prompt}\n…`;
963 try {
964 const resp = await fetch("/ai", {
965 method: "POST",
966 headers: { "content-type": "application/json" },
967 body: JSON.stringify({ prompt }),
968 });
969 const text = await resp.text();
970 let parsed;
971 try {
972 parsed = JSON.parse(text);
973 } catch {
974 parsed = text;
975 }
976 const out =
977 typeof parsed === "object" && parsed && "response" in parsed
978 ? parsed.response
979 : typeof parsed === "string"
980 ? parsed
981 : JSON.stringify(parsed, null, 2);
982 aiStatusEl.textContent = `> ${prompt}\n${out}`;
983 addMessage(`AI Gateway -> HTTP ${resp.status}`, true);
984 } catch (e) {
985 aiStatusEl.textContent = `Error: ${e.message}`;
986 } finally {
987 aiSendBtn.disabled = false;
988 }
989 });
990
991 const reminderSendBtn = document.getElementById("reminder-send");
992 const reminderDelayEl = document.getElementById("reminder-delay");
993 const reminderMsgEl = document.getElementById("reminder-msg");
994 const reminderStatusEl = document.getElementById("reminder-status");
995
996 reminderSendBtn.addEventListener("click", () => {
997 const delay = parseInt(reminderDelayEl.value, 10);
998 const msg = reminderMsgEl.value.trim() || "ping!";
999 if (!delay || delay < 1) {
1000 reminderStatusEl.textContent = "Delay must be at least 1 second.";
1001 return;
1002 }
1003 if (!ws || ws.readyState !== WebSocket.OPEN) {
1004 reminderStatusEl.textContent = "Not connected.";
1005 return;
1006 }
1007 ws.send(`/remind ${delay} ${msg}`);
1008 reminderStatusEl.textContent = `Sent: /remind ${delay} ${msg}`;
1009 reminderMsgEl.value = "";
1010 });
1011
1012 form.addEventListener("submit", (e) => {
1013 e.preventDefault();
1014 const text = input.value.trim();
1015 if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
1016 ws.send(text);
1017 input.value = "";
1018 });
1019
1020 connect();
1021 </script>
1022 </body>
1023</html>