Mercurial
diff mrjunejune/src/jrpg/jrpg.js @ 261:b401627fc49e
Add JRPG mock flows and interactive previews
Add scripted mock SSE commands, custom event forwarding, animated chat turns, full-height message navigation, and a cyberpunk resume dossier.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Wed, 05 Aug 2026 20:38:32 -0700 |
| parents | 1f9877b637e9 |
| children | 0f45474c1b1a |
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 20:38:32 2026 -0700 @@ -20,6 +20,38 @@ title: "Resume", type: "Profile", url: "/resume", + works: [ + { + detail: "Agentic execution", + label: "Copilot Tasks", + url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", + }, + { + detail: "AI platform", + label: "Copilot SuperApp", + url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html", + }, + { + detail: "Build 2026", + label: "Code", + url: "https://news.microsoft.com/build-2026/", + }, + { + detail: "Personal agent", + label: "Autopilot", + url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", + }, + { + detail: "Ads systems", + label: "Meta", + url: "https://www.meta.com/", + }, + { + detail: "Workspace platform", + label: "Google", + url: "https://www.google.com/", + }, + ], }, tools: { copy: "Small, focused utilities for writing, media, and experimentation.", @@ -27,6 +59,11 @@ title: "Tools", type: "Utilities", url: "/tools", + works: [ + { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, + { detail: "Media", label: "Converter", url: "/tools/file_converter" }, + { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, + ], }, blog: { copy: "Notes from building systems, games, web tools, and curious prototypes.", @@ -34,10 +71,102 @@ title: "Blogs", type: "Writing", url: "/blog", + works: [ + { detail: "Archive", label: "All posts", url: "/blog" }, + ], }, }); const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; +const TYPING_INTERVAL_MS = 18; + +class StreamingTextAnimator { + constructor(render) { + this._render = render; + this._displayed = ""; + this._target = ""; + this._timer = 0; + this._waiters = []; + this._reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + } + + append(text) { + if (typeof text !== "string" || !text) return; + this._target += text; + this._schedule(); + } + + complete(text) { + if (typeof text === "string") { + this._target = text; + if (!this._target.startsWith(this._displayed)) { + let shared = 0; + while ( + shared < this._target.length && + shared < this._displayed.length && + this._target[shared] === this._displayed[shared] + ) { + shared++; + } + this._displayed = this._displayed.slice(0, shared); + this._render(this._displayed); + } + } + this._schedule(); + } + + waitForIdle() { + if (!this._timer && this._displayed === this._target) { + return Promise.resolve(); + } + return new Promise(resolve => { + this._waiters.push(resolve); + }); + } + + cancel() { + window.clearTimeout(this._timer); + this._timer = 0; + this._target = this._displayed; + this._settle(); + } + + _schedule() { + if (this._reduceMotion) { + this._displayed = this._target; + this._render(this._displayed); + this._settle(); + return; + } + if (this._timer || this._displayed === this._target) return; + this._timer = window.setTimeout(() => this._tick(), TYPING_INTERVAL_MS); + } + + _tick() { + this._timer = 0; + const remaining = this._target.length - this._displayed.length; + if (remaining <= 0) { + this._settle(); + return; + } + const amount = Math.max(1, Math.ceil(remaining / 80)); + this._displayed = this._target.slice( + 0, + this._displayed.length + amount, + ); + this._render(this._displayed); + if (this._displayed === this._target) this._settle(); + else this._schedule(); + } + + _settle() { + if (this._timer || this._displayed !== this._target) return; + const waiters = this._waiters.splice(0); + for (const resolve of waiters) resolve(); + } +} class MjjJrpgCharacter extends HTMLElement { static get observedAttributes() { @@ -66,33 +195,89 @@ connectedCallback() { this._messages = this.querySelector("[data-messages]"); this._viewport = this.querySelector("[data-zen-viewport]"); + this._previousButton = this.querySelector("[data-turn-previous]"); + this._nextButton = this.querySelector("[data-turn-next]"); + this._position = this.querySelector("[data-turn-position]"); + this._initialMessages = [...(this._messages?.children || [])].map( + item => item.cloneNode(true), + ); + this._turnSequence = 0; + this._activeGroup = null; + this._onNavigationClick = event => { + const button = event.target.closest("button"); + if (!button || !this.contains(button) || button.disabled) return; + if (button === this._previousButton) this._moveTurn(-1); + else if (button === this._nextButton) this._moveTurn(1); + }; + this.addEventListener("click", this._onNavigationClick); + this._refreshTurnGroups("start"); + } + + disconnectedCallback() { + this.removeEventListener("click", this._onNavigationClick); } appendMessage(speaker, text) { + let group = this._activeGroup || "start"; + if (speaker === "June") { + this._turnSequence++; + group = `turn-${this._turnSequence}`; + this._activeGroup = group; + } + return this._createMessage(speaker, text, group, true); + } + + _createMessage(speaker, text, group, select) { 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; + item.dataset.turnGroup = group; name.textContent = speaker; copy.textContent = text; item.append(name, copy); this._messages.append(item); - this.scrollEnd(); + this._refreshTurnGroups(select ? group : this._currentGroup); 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( + this._turnSequence = 0; + this._activeGroup = null; + const visibleTurns = turns.filter(turn => + ["user", "assistant"].includes(turn.role) + ); + if (!visibleTurns.length) { + this._messages.append( + ...this._initialMessages.map(item => item.cloneNode(true)), + ); + this._refreshTurnGroups("start"); + return; + } + let group = null; + for (const turn of visibleTurns) { + if (turn.role === "user" || !group) { + this._turnSequence++; + group = `turn-${this._turnSequence}`; + this._activeGroup = group; + } + this._createMessage( turn.role === "user" ? "June" : "Epi", - turn.content || (turn.status === "failed" ? "The quest failed." : ""), + turn.content || + (turn.status === "aborted" + ? "Quest cancelled." + : turn.status === "failed" + ? "The quest failed." + : ""), + group, + false, ); } + this._refreshTurnGroups(group); } startAssistantMessage() { @@ -105,6 +290,64 @@ this.scrollEnd(); } + setMessageStreaming(item, streaming) { + if (!item) return; + if (streaming) item.dataset.streaming = "true"; + else delete item.dataset.streaming; + this._viewport?.setAttribute("aria-busy", String(streaming)); + } + + _refreshTurnGroups(preferredGroup) { + const groups = []; + for (const item of this._messages?.children || []) { + const group = item.dataset.turnGroup || "start"; + item.dataset.turnGroup = group; + if (!groups.includes(group)) groups.push(group); + } + this._turnGroups = groups; + const preferredIndex = groups.indexOf(preferredGroup); + const currentIndex = groups.indexOf(this._currentGroup); + this._turnIndex = preferredIndex >= 0 + ? preferredIndex + : currentIndex >= 0 + ? currentIndex + : Math.max(0, groups.length - 1); + this._showTurn(); + } + + _moveTurn(offset) { + const nextIndex = Math.max( + 0, + Math.min(this._turnGroups.length - 1, this._turnIndex + offset), + ); + if (nextIndex === this._turnIndex) return; + this._turnIndex = nextIndex; + this._showTurn(); + } + + _showTurn() { + const group = this._turnGroups?.[this._turnIndex]; + this._currentGroup = group; + for (const item of this._messages?.children || []) { + item.hidden = item.dataset.turnGroup !== group; + } + if (this._previousButton) this._previousButton.disabled = this._turnIndex <= 0; + if (this._nextButton) { + this._nextButton.disabled = + this._turnIndex >= (this._turnGroups?.length || 0) - 1; + } + if (this._position) { + if (group === "start") { + this._position.textContent = "START"; + } else { + const turns = this._turnGroups.filter(item => item !== "start"); + this._position.textContent = + `TURN ${turns.indexOf(group) + 1} / ${turns.length}`; + } + } + this.scrollEnd(); + } + scrollEnd() { requestAnimationFrame(() => { if (this._viewport) { @@ -161,6 +404,10 @@ if (this._cancelControl) this._cancelControl.hidden = !busy; } + setCancellable(cancellable) { + if (this._cancelControl) this._cancelControl.hidden = !cancellable; + } + setDisabled(disabled) { if (this._textarea) this._textarea.disabled = disabled; if (this._button) this._button.disabled = disabled; @@ -190,9 +437,22 @@ class MjjJrpgPreview extends HTMLElement { connectedCallback() { + this._onClick = event => { + if ( + event.target.closest("[data-zen-trigger]") && + this.dataset.selection === "resume" + ) { + void this.loadResume(); + } + }; + this.addEventListener("click", this._onClick); this.show(this.dataset.selection || "resume"); } + disconnectedCallback() { + this.removeEventListener("click", this._onClick); + } + show(selection) { const preview = PREVIEWS[selection] || PREVIEWS.resume; this.dataset.selection = selection; @@ -202,9 +462,79 @@ 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 showcase = this.querySelector("[data-work-showcase]"); + showcase.replaceChildren(...preview.works.map(work => { + const item = document.createElement("li"); + const link = document.createElement("a"); + const label = document.createElement("span"); + const detail = document.createElement("small"); + link.href = work.url; + if (new URL(work.url, window.location.href).origin !== window.location.origin) { + link.target = "_blank"; + link.rel = "noreferrer"; + } + label.textContent = work.label; + detail.textContent = work.detail; + link.append(label, detail); + item.append(link); + return item; + })); const link = this.querySelector("[data-preview-link]"); link.href = preview.url; link.setAttribute("aria-label", `Open ${preview.title}`); + const resumeDossier = this.querySelector("[data-resume-dossier]"); + const resumeDownload = this.querySelector("[data-resume-download]"); + const isResume = selection === "resume"; + resumeDossier.hidden = !isResume; + resumeDownload.hidden = !isResume; + this.querySelector("[data-dialog-copy]").hidden = isResume; + } + + async loadResume() { + if (this._resumeLoaded || this._resumeLoading) return; + this._resumeLoading = true; + const status = this.querySelector("[data-resume-status]"); + const content = this.querySelector("[data-resume-content]"); + try { + const response = await fetch("/resume", { + headers: { Accept: "text/html" }, + }); + if (!response.ok) { + throw new Error(`Resume request failed (${response.status})`); + } + const documentCopy = new DOMParser().parseFromString( + await response.text(), + "text/html", + ); + const resume = documentCopy.querySelector("main"); + if (!resume) throw new Error("Resume content is unavailable"); + for (const unsafe of resume.querySelectorAll( + "embed, iframe, object, script, style, svg", + )) { + unsafe.remove(); + } + for (const element of resume.querySelectorAll("*")) { + for (const attribute of [...element.attributes]) { + if (attribute.name.toLowerCase().startsWith("on")) { + element.removeAttribute(attribute.name); + } + } + } + for (const anchor of resume.querySelectorAll("a")) { + const target = new URL(anchor.href, window.location.href); + if (target.origin !== window.location.origin) { + anchor.target = "_blank"; + anchor.rel = "noreferrer"; + } + } + content.replaceChildren(...resume.childNodes); + status.hidden = true; + this._resumeLoaded = true; + } catch (error) { + status.textContent = `Unable to load resume: ${error.message}`; + } finally { + this._resumeLoading = false; + } } } @@ -327,9 +657,15 @@ character?.setAttribute("state", "thinking"); window.clearTimeout(characterTimer); const assistantItem = chat?.startAssistantMessage(); - let assistantText = ""; + chat?.setMessageStreaming(assistantItem, true); + const assistantText = new StreamingTextAnimator(text => { + chat?.setMessageText(assistantItem, text); + }); let turnFinished = false; activeController = new AbortController(); + activeController.signal.addEventListener("abort", () => { + assistantText.cancel(); + }, { once: true }); try { const createConversation = async () => { @@ -370,23 +706,42 @@ throw new Error(message); } await consumeSse(response, (eventName, data) => { + shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", { + bubbles: true, + detail: { + data, + type: eventName, + }, + })); if (eventName === "assistant.delta") { - assistantText += data.delta || ""; - chat?.setMessageText(assistantItem, assistantText); + assistantText.append(data.delta || ""); } else if (eventName === "assistant.completed") { - assistantText = data.content || assistantText; - chat?.setMessageText(assistantItem, assistantText); + assistantText.complete(data.content); } else if (eventName === "turn.error") { - throw new Error(data.message || "Inference turn failed"); + throw new Error( + data.error?.message || data.message || "Inference turn failed", + ); } else if (eventName === "turn.done") { turnFinished = true; - if (data.failed) throw new Error("Inference turn failed"); + composer?.setCancellable(false); + if (data.failed || data.aborted) { + throw new Error( + data.aborted ? "Inference turn aborted" : "Inference turn failed", + ); + } } }); if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); + await assistantText.waitForIdle(); + if (activeController.signal.aborted) { + throw new DOMException("Quest cancelled", "AbortError"); + } + chat?.setMessageStreaming(assistantItem, false); character?.setAttribute("state", "default"); } catch (error) { const aborted = error instanceof DOMException && error.name === "AbortError"; + assistantText.cancel(); + chat?.setMessageStreaming(assistantItem, false); chat?.setMessageText( assistantItem, aborted ? "Quest cancelled." : `System error: ${error.message}`,