Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 259:667156fcd3e3 | 260:1f9877b637e9 |
|---|---|
| 34 title: "Blogs", | 34 title: "Blogs", |
| 35 type: "Writing", | 35 type: "Writing", |
| 36 url: "/blog", | 36 url: "/blog", |
| 37 }, | 37 }, |
| 38 }); | 38 }); |
| 39 | |
| 40 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; | |
| 39 | 41 |
| 40 class MjjJrpgCharacter extends HTMLElement { | 42 class MjjJrpgCharacter extends HTMLElement { |
| 41 static get observedAttributes() { | 43 static get observedAttributes() { |
| 42 return ["state"]; | 44 return ["state"]; |
| 43 } | 45 } |
| 75 item.dataset.speaker = speaker; | 77 item.dataset.speaker = speaker; |
| 76 name.textContent = speaker; | 78 name.textContent = speaker; |
| 77 copy.textContent = text; | 79 copy.textContent = text; |
| 78 item.append(name, copy); | 80 item.append(name, copy); |
| 79 this._messages.append(item); | 81 this._messages.append(item); |
| 82 this.scrollEnd(); | |
| 83 return item; | |
| 84 } | |
| 85 | |
| 86 replaceMessages(turns) { | |
| 87 if (!this._messages) return; | |
| 88 this._messages.replaceChildren(); | |
| 89 for (const turn of turns) { | |
| 90 if (!["user", "assistant"].includes(turn.role)) continue; | |
| 91 this.appendMessage( | |
| 92 turn.role === "user" ? "June" : "Epi", | |
| 93 turn.content || (turn.status === "failed" ? "The quest failed." : ""), | |
| 94 ); | |
| 95 } | |
| 96 } | |
| 97 | |
| 98 startAssistantMessage() { | |
| 99 return this.appendMessage("Epi", ""); | |
| 100 } | |
| 101 | |
| 102 setMessageText(item, text) { | |
| 103 const copy = item?.querySelector("p"); | |
| 104 if (copy) copy.textContent = text; | |
| 105 this.scrollEnd(); | |
| 106 } | |
| 107 | |
| 108 scrollEnd() { | |
| 80 requestAnimationFrame(() => { | 109 requestAnimationFrame(() => { |
| 81 if (this._viewport) { | 110 if (this._viewport) { |
| 82 this._viewport.scrollTop = this._viewport.scrollHeight; | 111 this._viewport.scrollTop = this._viewport.scrollHeight; |
| 83 } | 112 } |
| 84 }); | 113 }); |
| 88 class MjjJrpgComposer extends HTMLElement { | 117 class MjjJrpgComposer extends HTMLElement { |
| 89 connectedCallback() { | 118 connectedCallback() { |
| 90 this._form = this.querySelector("[data-composer]"); | 119 this._form = this.querySelector("[data-composer]"); |
| 91 this._textarea = this.querySelector("textarea"); | 120 this._textarea = this.querySelector("textarea"); |
| 92 this._button = this.querySelector('button[type="submit"]'); | 121 this._button = this.querySelector('button[type="submit"]'); |
| 122 this._cancel = this.querySelector("[data-cancel]"); | |
| 123 this._cancelControl = this.querySelector("[data-cancel-control]"); | |
| 93 this._onSubmit = event => { | 124 this._onSubmit = event => { |
| 94 event.preventDefault(); | 125 event.preventDefault(); |
| 95 const message = this._textarea?.value.trim(); | 126 const message = this._textarea?.value.trim(); |
| 96 if (!message || this._button?.disabled) return; | 127 if (!message || this._button?.disabled) return; |
| 97 this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", { | 128 this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", { |
| 105 event.preventDefault(); | 136 event.preventDefault(); |
| 106 this._form?.requestSubmit(); | 137 this._form?.requestSubmit(); |
| 107 }; | 138 }; |
| 108 this._form?.addEventListener("submit", this._onSubmit); | 139 this._form?.addEventListener("submit", this._onSubmit); |
| 109 this._textarea?.addEventListener("keydown", this._onKeydown); | 140 this._textarea?.addEventListener("keydown", this._onKeydown); |
| 141 this._onCancel = () => { | |
| 142 this.dispatchEvent(new CustomEvent("mjj-jrpg-cancel", { | |
| 143 bubbles: true, | |
| 144 })); | |
| 145 }; | |
| 146 this._cancel?.addEventListener("click", this._onCancel); | |
| 110 } | 147 } |
| 111 | 148 |
| 112 disconnectedCallback() { | 149 disconnectedCallback() { |
| 113 this._form?.removeEventListener("submit", this._onSubmit); | 150 this._form?.removeEventListener("submit", this._onSubmit); |
| 114 this._textarea?.removeEventListener("keydown", this._onKeydown); | 151 this._textarea?.removeEventListener("keydown", this._onKeydown); |
| 152 this._cancel?.removeEventListener("click", this._onCancel); | |
| 115 } | 153 } |
| 116 | 154 |
| 117 setBusy(busy) { | 155 setBusy(busy) { |
| 118 if (this._textarea) this._textarea.disabled = busy; | 156 if (this._textarea) this._textarea.disabled = busy; |
| 119 if (this._button) { | 157 if (this._button) { |
| 120 this._button.disabled = busy; | 158 this._button.disabled = busy; |
| 121 this._button.setAttribute("aria-busy", String(busy)); | 159 this._button.setAttribute("aria-busy", String(busy)); |
| 122 } | 160 } |
| 161 if (this._cancelControl) this._cancelControl.hidden = !busy; | |
| 162 } | |
| 163 | |
| 164 setDisabled(disabled) { | |
| 165 if (this._textarea) this._textarea.disabled = disabled; | |
| 166 if (this._button) this._button.disabled = disabled; | |
| 123 } | 167 } |
| 124 } | 168 } |
| 125 | 169 |
| 126 class MjjJrpgMenu extends HTMLElement { | 170 class MjjJrpgMenu extends HTMLElement { |
| 127 connectedCallback() { | 171 connectedCallback() { |
| 175 "mjj-jrpg-shell": MjjJrpgShell, | 219 "mjj-jrpg-shell": MjjJrpgShell, |
| 176 })) { | 220 })) { |
| 177 if (!customElements.get(name)) customElements.define(name, constructor); | 221 if (!customElements.get(name)) customElements.define(name, constructor); |
| 178 } | 222 } |
| 179 | 223 |
| 180 function initializeJrpgPage() { | 224 async function requestJson(url, options = {}) { |
| 225 const response = await fetch(url, { | |
| 226 ...options, | |
| 227 headers: { | |
| 228 "Content-Type": "application/json", | |
| 229 ...options.headers, | |
| 230 }, | |
| 231 }); | |
| 232 if (!response.ok) { | |
| 233 let message = `Request failed (${response.status})`; | |
| 234 try { | |
| 235 const body = await response.json(); | |
| 236 message = body.error?.message || message; | |
| 237 } catch { | |
| 238 // Preserve the status-derived message. | |
| 239 } | |
| 240 throw new Error(message); | |
| 241 } | |
| 242 return response.status === 204 ? null : response.json(); | |
| 243 } | |
| 244 | |
| 245 async function consumeSse(response, onEvent) { | |
| 246 if (!response.body) throw new Error("Streaming response body is unavailable"); | |
| 247 const reader = response.body.getReader(); | |
| 248 const decoder = new TextDecoder(); | |
| 249 let buffer = ""; | |
| 250 | |
| 251 const dispatch = block => { | |
| 252 let eventName = "message"; | |
| 253 const data = []; | |
| 254 for (const line of block.split("\n")) { | |
| 255 if (line.startsWith("event:")) eventName = line.slice(6).trim(); | |
| 256 else if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); | |
| 257 } | |
| 258 if (!data.length) return; | |
| 259 onEvent(eventName, JSON.parse(data.join("\n"))); | |
| 260 }; | |
| 261 | |
| 262 while (true) { | |
| 263 const { done, value } = await reader.read(); | |
| 264 buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); | |
| 265 buffer = buffer.replaceAll("\r\n", "\n"); | |
| 266 let boundary; | |
| 267 while ((boundary = buffer.indexOf("\n\n")) >= 0) { | |
| 268 const block = buffer.slice(0, boundary); | |
| 269 buffer = buffer.slice(boundary + 2); | |
| 270 if (block.trim()) dispatch(block); | |
| 271 } | |
| 272 if (done) break; | |
| 273 } | |
| 274 if (buffer.trim()) dispatch(buffer); | |
| 275 } | |
| 276 | |
| 277 async function initializeJrpgPage() { | |
| 181 const colorProbe = document.createElement("span"); | 278 const colorProbe = document.createElement("span"); |
| 182 colorProbe.style.background = "var(--zenbu-sys-color-surface-page)"; | 279 colorProbe.style.background = "var(--zenbu-sys-color-surface-page)"; |
| 183 document.body.append(colorProbe); | 280 document.body.append(colorProbe); |
| 184 const themeColor = getComputedStyle(colorProbe).backgroundColor; | 281 const themeColor = getComputedStyle(colorProbe).backgroundColor; |
| 185 colorProbe.remove(); | 282 colorProbe.remove(); |
| 191 const character = shell?.querySelector("mjj-jrpg-character"); | 288 const character = shell?.querySelector("mjj-jrpg-character"); |
| 192 const chat = shell?.querySelector("mjj-jrpg-chat"); | 289 const chat = shell?.querySelector("mjj-jrpg-chat"); |
| 193 const composer = shell?.querySelector("mjj-jrpg-composer"); | 290 const composer = shell?.querySelector("mjj-jrpg-composer"); |
| 194 const preview = shell?.querySelector("mjj-jrpg-preview"); | 291 const preview = shell?.querySelector("mjj-jrpg-preview"); |
| 195 let characterTimer = 0; | 292 let characterTimer = 0; |
| 196 let replyTimer = 0; | 293 let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); |
| 294 let activeController = null; | |
| 295 composer?.setDisabled(true); | |
| 296 | |
| 297 if (conversationId) { | |
| 298 try { | |
| 299 const conversation = await requestJson( | |
| 300 `/api/conversations/${encodeURIComponent(conversationId)}`, | |
| 301 { headers: {} }, | |
| 302 ); | |
| 303 chat?.replaceMessages(conversation.turns); | |
| 304 } catch { | |
| 305 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 306 conversationId = null; | |
| 307 } | |
| 308 } | |
| 197 | 309 |
| 198 shell?.addEventListener("mjj-jrpg-preview-change", event => { | 310 shell?.addEventListener("mjj-jrpg-preview-change", event => { |
| 199 preview?.show(event.detail.selection); | 311 preview?.show(event.detail.selection); |
| 200 character?.setAttribute("state", "thinking"); | 312 character?.setAttribute("state", "thinking"); |
| 201 window.clearTimeout(characterTimer); | 313 window.clearTimeout(characterTimer); |
| 202 characterTimer = window.setTimeout(() => { | 314 characterTimer = window.setTimeout(() => { |
| 203 character?.setAttribute("state", "default"); | 315 character?.setAttribute("state", "default"); |
| 204 }, 650); | 316 }, 650); |
| 205 }); | 317 }); |
| 206 | 318 |
| 207 shell?.addEventListener("mjj-jrpg-submit", event => { | 319 shell?.addEventListener("mjj-jrpg-cancel", () => { |
| 320 activeController?.abort(); | |
| 321 }); | |
| 322 | |
| 323 shell?.addEventListener("mjj-jrpg-submit", async event => { | |
| 324 if (activeController) return; | |
| 208 chat?.appendMessage("June", event.detail.message); | 325 chat?.appendMessage("June", event.detail.message); |
| 209 composer?.setBusy(true); | 326 composer?.setBusy(true); |
| 210 character?.setAttribute("state", "thinking"); | 327 character?.setAttribute("state", "thinking"); |
| 211 window.clearTimeout(characterTimer); | 328 window.clearTimeout(characterTimer); |
| 212 window.clearTimeout(replyTimer); | 329 const assistantItem = chat?.startAssistantMessage(); |
| 213 replyTimer = window.setTimeout(() => { | 330 let assistantText = ""; |
| 214 chat?.appendMessage( | 331 let turnFinished = false; |
| 215 "Epi", | 332 activeController = new AbortController(); |
| 216 "A promising quest. The menu has the fastest paths through this site.", | 333 |
| 334 try { | |
| 335 const createConversation = async () => { | |
| 336 const conversation = await requestJson("/api/conversations", { | |
| 337 method: "POST", | |
| 338 body: JSON.stringify({ title: "Shiba Quest" }), | |
| 339 }); | |
| 340 conversationId = conversation.id; | |
| 341 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId); | |
| 342 }; | |
| 343 if (!conversationId) { | |
| 344 await createConversation(); | |
| 345 } | |
| 346 | |
| 347 const postTurn = () => fetch( | |
| 348 `/api/conversations/${encodeURIComponent(conversationId)}/turns`, { | |
| 349 method: "POST", | |
| 350 headers: { "Content-Type": "application/json" }, | |
| 351 body: JSON.stringify({ prompt: event.detail.message }), | |
| 352 signal: activeController.signal, | |
| 353 }, | |
| 217 ); | 354 ); |
| 355 let response = await postTurn(); | |
| 356 if (response.status === 404) { | |
| 357 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 358 conversationId = null; | |
| 359 await createConversation(); | |
| 360 response = await postTurn(); | |
| 361 } | |
| 362 if (!response.ok) { | |
| 363 let message = `Turn failed (${response.status})`; | |
| 364 try { | |
| 365 const body = await response.json(); | |
| 366 message = body.error?.message || message; | |
| 367 } catch { | |
| 368 // Preserve the status-derived message. | |
| 369 } | |
| 370 throw new Error(message); | |
| 371 } | |
| 372 await consumeSse(response, (eventName, data) => { | |
| 373 if (eventName === "assistant.delta") { | |
| 374 assistantText += data.delta || ""; | |
| 375 chat?.setMessageText(assistantItem, assistantText); | |
| 376 } else if (eventName === "assistant.completed") { | |
| 377 assistantText = data.content || assistantText; | |
| 378 chat?.setMessageText(assistantItem, assistantText); | |
| 379 } else if (eventName === "turn.error") { | |
| 380 throw new Error(data.message || "Inference turn failed"); | |
| 381 } else if (eventName === "turn.done") { | |
| 382 turnFinished = true; | |
| 383 if (data.failed) throw new Error("Inference turn failed"); | |
| 384 } | |
| 385 }); | |
| 386 if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); | |
| 218 character?.setAttribute("state", "default"); | 387 character?.setAttribute("state", "default"); |
| 388 } catch (error) { | |
| 389 const aborted = error instanceof DOMException && error.name === "AbortError"; | |
| 390 chat?.setMessageText( | |
| 391 assistantItem, | |
| 392 aborted ? "Quest cancelled." : `System error: ${error.message}`, | |
| 393 ); | |
| 394 character?.setAttribute("state", "sad"); | |
| 395 } finally { | |
| 219 composer?.setBusy(false); | 396 composer?.setBusy(false); |
| 220 composer?.querySelector("textarea")?.focus(); | 397 composer?.querySelector("textarea")?.focus(); |
| 221 }, 700); | 398 activeController = null; |
| 399 } | |
| 222 }); | 400 }); |
| 401 composer?.setDisabled(false); | |
| 223 } | 402 } |
| 224 | 403 |
| 225 if (document.readyState === "loading") { | 404 if (document.readyState === "loading") { |
| 226 document.addEventListener("DOMContentLoaded", initializeJrpgPage, { | 405 document.addEventListener("DOMContentLoaded", () => { |
| 406 void initializeJrpgPage(); | |
| 407 }, { | |
| 227 once: true, | 408 once: true, |
| 228 }); | 409 }); |
| 229 } else { | 410 } else { |
| 230 initializeJrpgPage(); | 411 void initializeJrpgPage(); |
| 231 } | 412 } |