Mercurial
view 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 source
const CHARACTER_STATES = Object.freeze({ default: { alt: "Epi the Shiba Inu standing happily", src: "/public/jrpg/shiba-default.webp", }, sad: { alt: "Epi the Shiba Inu sitting sadly", src: "/public/jrpg/shiba-sad.webp", }, thinking: { alt: "Epi the Shiba Inu tilting her head in thought", src: "/public/jrpg/shiba-thinking.webp", }, }); const PREVIEWS = Object.freeze({ resume: { copy: "Experience, projects, and the systems I have helped build.", kicker: "CHARACTER RECORD", title: "Resume", type: "Profile", url: "/resume", }, tools: { copy: "Small, focused utilities for writing, media, and experimentation.", kicker: "ITEM INVENTORY", title: "Tools", type: "Utilities", url: "/tools", }, blog: { copy: "Notes from building systems, games, web tools, and curious prototypes.", kicker: "QUEST ARCHIVE", title: "Blogs", type: "Writing", url: "/blog", }, }); const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; class MjjJrpgCharacter extends HTMLElement { static get observedAttributes() { return ["state"]; } connectedCallback() { this.render(); } attributeChangedCallback() { if (this.isConnected) this.render(); } render() { const image = this.querySelector("[data-character]"); if (!image) return; const state = CHARACTER_STATES[this.getAttribute("state")] || CHARACTER_STATES.default; image.src = state.src; image.alt = state.alt; } } class MjjJrpgChat extends HTMLElement { connectedCallback() { this._messages = this.querySelector("[data-messages]"); this._viewport = this.querySelector("[data-zen-viewport]"); } appendMessage(speaker, text) { if (!this._messages) return; const item = document.createElement("li"); const name = document.createElement("strong"); const copy = document.createElement("p"); item.className = "jrpg-message"; item.dataset.speaker = speaker; name.textContent = speaker; 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; } }); } } class MjjJrpgComposer extends HTMLElement { connectedCallback() { 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(); if (!message || this._button?.disabled) return; this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", { bubbles: true, detail: { message }, })); this._form.reset(); }; this._onKeydown = event => { if (event.key !== "Enter" || event.shiftKey || event.isComposing) return; event.preventDefault(); this._form?.requestSubmit(); }; 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) { if (this._textarea) this._textarea.disabled = busy; if (this._button) { 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; } } class MjjJrpgMenu extends HTMLElement { connectedCallback() { this._onClick = event => { const button = event.target.closest("button[data-preview]"); if (!button || !this.contains(button)) return; for (const item of this.querySelectorAll("button[data-preview]")) { item.setAttribute("aria-pressed", String(item === button)); } this.dispatchEvent(new CustomEvent("mjj-jrpg-preview-change", { bubbles: true, detail: { selection: button.dataset.preview }, })); }; this.addEventListener("click", this._onClick); } disconnectedCallback() { this.removeEventListener("click", this._onClick); } } class MjjJrpgPreview extends HTMLElement { connectedCallback() { this.show(this.dataset.selection || "resume"); } show(selection) { const preview = PREVIEWS[selection] || PREVIEWS.resume; this.dataset.selection = selection; this.querySelector("[data-preview-kicker]").textContent = preview.kicker; this.querySelector("[data-preview-title]").textContent = preview.title; this.querySelector("[data-preview-copy]").textContent = preview.copy; this.querySelector("[data-preview-type]").textContent = preview.type; this.querySelector("[data-dialog-title]").textContent = preview.title; this.querySelector("[data-dialog-copy]").textContent = preview.copy; const link = this.querySelector("[data-preview-link]"); link.href = preview.url; link.setAttribute("aria-label", `Open ${preview.title}`); } } class MjjJrpgShell extends HTMLElement {} for (const [name, constructor] of Object.entries({ "mjj-jrpg-character": MjjJrpgCharacter, "mjj-jrpg-chat": MjjJrpgChat, "mjj-jrpg-composer": MjjJrpgComposer, "mjj-jrpg-menu": MjjJrpgMenu, "mjj-jrpg-preview": MjjJrpgPreview, "mjj-jrpg-shell": MjjJrpgShell, })) { if (!customElements.get(name)) customElements.define(name, constructor); } 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); const themeColor = getComputedStyle(colorProbe).backgroundColor; colorProbe.remove(); for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { meta.setAttribute("content", themeColor); } const shell = document.querySelector("mjj-jrpg-shell"); const character = shell?.querySelector("mjj-jrpg-character"); const chat = shell?.querySelector("mjj-jrpg-chat"); const composer = shell?.querySelector("mjj-jrpg-composer"); const preview = shell?.querySelector("mjj-jrpg-preview"); let characterTimer = 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); character?.setAttribute("state", "thinking"); window.clearTimeout(characterTimer); characterTimer = window.setTimeout(() => { character?.setAttribute("state", "default"); }, 650); }); 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); 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(); activeController = null; } }); composer?.setDisabled(false); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { void initializeJrpgPage(); }, { once: true, }); } else { void initializeJrpgPage(); }