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 }