Mercurial
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(); }