diff mrjunejune/src/jrpg/jrpg.js @ 260:1f9877b637e9

Add Copilot-powered cyberpunk JRPG chat Integrate the production JRPG chat with Seobeo streaming, Deita persistence, and a Bazel-managed Copilot SDK and LiteLLM inference stack. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Wed, 05 Aug 2026 09:19:41 -0700
parents 667156fcd3e3
children b401627fc49e
line wrap: on
line diff
--- a/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 09:19:41 2026 -0700
+++ b/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 09:19:41 2026 -0700
@@ -37,6 +37,8 @@
   },
 });
 
+const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id";
+
 class MjjJrpgCharacter extends HTMLElement {
   static get observedAttributes() {
     return ["state"];
@@ -77,6 +79,33 @@
     copy.textContent = text;
     item.append(name, copy);
     this._messages.append(item);
+    this.scrollEnd();
+    return item;
+  }
+
+  replaceMessages(turns) {
+    if (!this._messages) return;
+    this._messages.replaceChildren();
+    for (const turn of turns) {
+      if (!["user", "assistant"].includes(turn.role)) continue;
+      this.appendMessage(
+        turn.role === "user" ? "June" : "Epi",
+        turn.content || (turn.status === "failed" ? "The quest failed." : ""),
+      );
+    }
+  }
+
+  startAssistantMessage() {
+    return this.appendMessage("Epi", "");
+  }
+
+  setMessageText(item, text) {
+    const copy = item?.querySelector("p");
+    if (copy) copy.textContent = text;
+    this.scrollEnd();
+  }
+
+  scrollEnd() {
     requestAnimationFrame(() => {
       if (this._viewport) {
         this._viewport.scrollTop = this._viewport.scrollHeight;
@@ -90,6 +119,8 @@
     this._form = this.querySelector("[data-composer]");
     this._textarea = this.querySelector("textarea");
     this._button = this.querySelector('button[type="submit"]');
+    this._cancel = this.querySelector("[data-cancel]");
+    this._cancelControl = this.querySelector("[data-cancel-control]");
     this._onSubmit = event => {
       event.preventDefault();
       const message = this._textarea?.value.trim();
@@ -107,11 +138,18 @@
     };
     this._form?.addEventListener("submit", this._onSubmit);
     this._textarea?.addEventListener("keydown", this._onKeydown);
+    this._onCancel = () => {
+      this.dispatchEvent(new CustomEvent("mjj-jrpg-cancel", {
+        bubbles: true,
+      }));
+    };
+    this._cancel?.addEventListener("click", this._onCancel);
   }
 
   disconnectedCallback() {
     this._form?.removeEventListener("submit", this._onSubmit);
     this._textarea?.removeEventListener("keydown", this._onKeydown);
+    this._cancel?.removeEventListener("click", this._onCancel);
   }
 
   setBusy(busy) {
@@ -120,6 +158,12 @@
       this._button.disabled = busy;
       this._button.setAttribute("aria-busy", String(busy));
     }
+    if (this._cancelControl) this._cancelControl.hidden = !busy;
+  }
+
+  setDisabled(disabled) {
+    if (this._textarea) this._textarea.disabled = disabled;
+    if (this._button) this._button.disabled = disabled;
   }
 }
 
@@ -177,7 +221,60 @@
   if (!customElements.get(name)) customElements.define(name, constructor);
 }
 
-function initializeJrpgPage() {
+async function requestJson(url, options = {}) {
+  const response = await fetch(url, {
+    ...options,
+    headers: {
+      "Content-Type": "application/json",
+      ...options.headers,
+    },
+  });
+  if (!response.ok) {
+    let message = `Request failed (${response.status})`;
+    try {
+      const body = await response.json();
+      message = body.error?.message || message;
+    } catch {
+      // Preserve the status-derived message.
+    }
+    throw new Error(message);
+  }
+  return response.status === 204 ? null : response.json();
+}
+
+async function consumeSse(response, onEvent) {
+  if (!response.body) throw new Error("Streaming response body is unavailable");
+  const reader = response.body.getReader();
+  const decoder = new TextDecoder();
+  let buffer = "";
+
+  const dispatch = block => {
+    let eventName = "message";
+    const data = [];
+    for (const line of block.split("\n")) {
+      if (line.startsWith("event:")) eventName = line.slice(6).trim();
+      else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
+    }
+    if (!data.length) return;
+    onEvent(eventName, JSON.parse(data.join("\n")));
+  };
+
+  while (true) {
+    const { done, value } = await reader.read();
+    buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
+    buffer = buffer.replaceAll("\r\n", "\n");
+    let boundary;
+    while ((boundary = buffer.indexOf("\n\n")) >= 0) {
+      const block = buffer.slice(0, boundary);
+      buffer = buffer.slice(boundary + 2);
+      if (block.trim()) dispatch(block);
+    }
+    if (done) break;
+  }
+  if (buffer.trim()) dispatch(buffer);
+}
+
+async function initializeJrpgPage() {
   const colorProbe = document.createElement("span");
   colorProbe.style.background = "var(--zenbu-sys-color-surface-page)";
   document.body.append(colorProbe);
@@ -193,7 +290,22 @@
   const composer = shell?.querySelector("mjj-jrpg-composer");
   const preview = shell?.querySelector("mjj-jrpg-preview");
   let characterTimer = 0;
-  let replyTimer = 0;
+  let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
+  let activeController = null;
+  composer?.setDisabled(true);
+
+  if (conversationId) {
+    try {
+      const conversation = await requestJson(
+        `/api/conversations/${encodeURIComponent(conversationId)}`,
+        { headers: {} },
+      );
+      chat?.replaceMessages(conversation.turns);
+    } catch {
+      sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+      conversationId = null;
+    }
+  }
 
   shell?.addEventListener("mjj-jrpg-preview-change", event => {
     preview?.show(event.detail.selection);
@@ -204,28 +316,97 @@
     }, 650);
   });
 
-  shell?.addEventListener("mjj-jrpg-submit", event => {
+  shell?.addEventListener("mjj-jrpg-cancel", () => {
+    activeController?.abort();
+  });
+
+  shell?.addEventListener("mjj-jrpg-submit", async event => {
+    if (activeController) return;
     chat?.appendMessage("June", event.detail.message);
     composer?.setBusy(true);
     character?.setAttribute("state", "thinking");
     window.clearTimeout(characterTimer);
-    window.clearTimeout(replyTimer);
-    replyTimer = window.setTimeout(() => {
-      chat?.appendMessage(
-        "Epi",
-        "A promising quest. The menu has the fastest paths through this site.",
+    const assistantItem = chat?.startAssistantMessage();
+    let assistantText = "";
+    let turnFinished = false;
+    activeController = new AbortController();
+
+    try {
+      const createConversation = async () => {
+        const conversation = await requestJson("/api/conversations", {
+          method: "POST",
+          body: JSON.stringify({ title: "Shiba Quest" }),
+        });
+        conversationId = conversation.id;
+        sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId);
+      };
+      if (!conversationId) {
+        await createConversation();
+      }
+
+      const postTurn = () => fetch(
+        `/api/conversations/${encodeURIComponent(conversationId)}/turns`, {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ prompt: event.detail.message }),
+          signal: activeController.signal,
+        },
       );
+      let response = await postTurn();
+      if (response.status === 404) {
+        sessionStorage.removeItem(CONVERSATION_STORAGE_KEY);
+        conversationId = null;
+        await createConversation();
+        response = await postTurn();
+      }
+      if (!response.ok) {
+        let message = `Turn failed (${response.status})`;
+        try {
+          const body = await response.json();
+          message = body.error?.message || message;
+        } catch {
+          // Preserve the status-derived message.
+        }
+        throw new Error(message);
+      }
+      await consumeSse(response, (eventName, data) => {
+        if (eventName === "assistant.delta") {
+          assistantText += data.delta || "";
+          chat?.setMessageText(assistantItem, assistantText);
+        } else if (eventName === "assistant.completed") {
+          assistantText = data.content || assistantText;
+          chat?.setMessageText(assistantItem, assistantText);
+        } else if (eventName === "turn.error") {
+          throw new Error(data.message || "Inference turn failed");
+        } else if (eventName === "turn.done") {
+          turnFinished = true;
+          if (data.failed) throw new Error("Inference turn failed");
+        }
+      });
+      if (!turnFinished) throw new Error("Inference stream ended unexpectedly");
       character?.setAttribute("state", "default");
+    } catch (error) {
+      const aborted = error instanceof DOMException && error.name === "AbortError";
+      chat?.setMessageText(
+        assistantItem,
+        aborted ? "Quest cancelled." : `System error: ${error.message}`,
+      );
+      character?.setAttribute("state", "sad");
+    } finally {
       composer?.setBusy(false);
       composer?.querySelector("textarea")?.focus();
-    }, 700);
+      activeController = null;
+    }
   });
+  composer?.setDisabled(false);
 }
 
 if (document.readyState === "loading") {
-  document.addEventListener("DOMContentLoaded", initializeJrpgPage, {
+  document.addEventListener("DOMContentLoaded", () => {
+    void initializeJrpgPage();
+  }, {
     once: true,
   });
 } else {
-  initializeJrpgPage();
+  void initializeJrpgPage();
 }