Mercurial
view mrjunejune/src/jrpg/jrpg.js @ 272:41a49c29a28f
polish JRPG conversation experience
Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 16:05:29 -0700 |
| parents | 056790c4fb0d |
| children | e02e2036ef84 |
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: "Member of Technical Staff and engineering leader with 10+ years building AI agent platforms and production systems across Microsoft, Meta, Google, and growth-stage companies.", title: "Resume", url: "/resume", works: [ { detail: "Career dossier", label: "Full resume", modal: "resume", url: "/resume", }, { detail: "Led engineering", label: "Copilot Tasks", url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", }, { detail: "Foundational platform", label: "AIX Harness / Copilot", 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 products", label: "Code & Autopilot", url: "https://news.microsoft.com/build-2026/", }, { detail: "Full-stack ads systems", label: "Meta", url: "https://www.meta.com/", }, { detail: "Workspace platform", label: "Google", url: "https://www.google.com/", }, { detail: "Technical lead", label: "Warner Music Group", url: "https://www.wmg.com/", }, { detail: "Personal agent launch", label: "Microsoft Scout", url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", }, ], }, tools: { copy: "Useful browser tools backed by first-party C, WASM, media, and document-processing systems.", title: "Tools", 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: "Technical writing about networking, rendering, performance, developer tooling, and experiments.", title: "Blogs", url: "/blog", works: [ { detail: "Archive", label: "All posts", url: "/blog" }, ], }, }); const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; const CONVERSATION_LEGACY_KEY = "mjj-jrpg-legacy-claim"; const ARCHIVE_PAGE_SIZE = 20; const TYPING_INTERVAL_MS = 18; const SCRIPT_LOADS = new Map(); const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const VALID_PANELS = Object.freeze(["resume", "tools", "blog", "conversations"]); const ROLE_GREETINGS = Object.freeze({ guest: "Hi, my name is Epi. June usually calls me Epi-chan. I’m here to help you navigate his personal website and learn about his work, projects, and writing.", invited: "Hi! I’m Epi, but June usually calls me Epi-chan. Since June invited you, I can be a little more casual. I can help you explore his site, talk about his projects, or brainstorm simple ideas with you.", admin: "Hi June! I’m Epi-chan. I’m here to help you review your personal website, think through your projects, and draft or refine public-facing ideas.", }); const TOOL_ICON_NAMES = Object.freeze({ "/notes": "repository", "/tools/file_converter": "retry", "/tools/hls_player": "play", "/tools/latex_editor": "file", "/tools/markdown_to_html": "code", }); function loadScript(source) { if (SCRIPT_LOADS.has(source)) return SCRIPT_LOADS.get(source); const loading = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = source; script.addEventListener("load", resolve, { once: true }); script.addEventListener("error", () => { SCRIPT_LOADS.delete(source); script.remove(); reject(new Error(`Unable to load ${source}`)); }, { once: true }); document.head.append(script); }); SCRIPT_LOADS.set(source, loading); return loading; } function greetingForSession(sessionData, hasConversations = false) { const greeting = sessionData?.kind !== "user" ? ROLE_GREETINGS.guest : sessionData.role === "admin" ? ROLE_GREETINGS.admin : ROLE_GREETINGS.invited; return hasConversations ? `${greeting} We’ve talked before. Open Conversations if you’d like to continue one.` : greeting; } 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() { 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]"); this._previousButton = this.querySelector("[data-turn-previous]"); this._nextButton = this.querySelector("[data-turn-next]"); this._position = this.querySelector("[data-turn-position]"); this._greetingText = ROLE_GREETINGS.guest; this._greetingAnimator = null; 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._greetingAnimator?.cancel(); this._greetingAnimator = null; const greeting = this._messages?.querySelector("[data-greeting]"); if (greeting) this.setMessageStreaming(greeting, false); this._turnSequence++; group = `turn-${this._turnSequence}`; this._activeGroup = group; } return this._createMessage(speaker, text, group, true); } setGreeting(text, animate = true) { this._greetingText = typeof text === "string" && text ? text : ROLE_GREETINGS.guest; this._showGreeting(animate); } _showGreeting(animate) { if (!this._messages) return; this._greetingAnimator?.cancel(); this._greetingAnimator = null; this._messages.replaceChildren(); this._turnSequence = 0; this._activeGroup = null; const item = this._createMessage("Epi", "", "start", false); if (!item) return; item.dataset.greeting = "true"; this._refreshTurnGroups("start"); if (!animate) { this.setMessageText(item, this._greetingText); this.setMessageStreaming(item, false); return; } this.setMessageStreaming(item, true); const animator = new StreamingTextAnimator(value => { this.setMessageText(item, value); }); this._greetingAnimator = animator; animator.complete(this._greetingText); void animator.waitForIdle().then(() => { if (this._greetingAnimator !== animator) return; this.setMessageStreaming(item, false); this._greetingAnimator = null; }); } _createMessage(speaker, text, group, select) { if (!this._messages) return; const item = document.createElement("li"); const speakerCell = document.createElement("span"); const name = document.createElement("strong"); const copy = document.createElement("p"); item.className = "jrpg-message"; item.dataset.speaker = speaker; item.dataset.turnGroup = group; speakerCell.className = "jrpg-message-speaker"; if (speaker === "Epi") { const avatar = document.createElement("img"); avatar.className = "jrpg-message-avatar"; avatar.src = "/public/jrpg/shiba-default.webp"; avatar.alt = ""; avatar.setAttribute("aria-hidden", "true"); speakerCell.append(avatar); } name.textContent = speaker; copy.textContent = text; speakerCell.append(name); item.append(speakerCell, copy); this._messages.append(item); this._refreshTurnGroups(select ? group : this._currentGroup); return item; } replaceMessages(turns) { if (!this._messages) return; this._greetingAnimator?.cancel(); this._greetingAnimator = null; this._messages.replaceChildren(); this._turnSequence = 0; this._activeGroup = null; const visibleTurns = turns.filter(turn => ["user", "assistant"].includes(turn.role) ); if (!visibleTurns.length) { this._showGreeting(true); 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 === "aborted" ? "Quest cancelled." : turn.status === "failed" ? "The quest failed." : ""), group, false, ); } this._refreshTurnGroups(group); } startAssistantMessage() { return this.appendMessage("Epi", ""); } setMessageText(item, text) { const copy = item?.querySelector("p"); if (copy) copy.textContent = text; 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) { this._viewport.scrollTop = this._viewport.scrollHeight; } }); } } 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); } } function sanitizeFetchedMain(main) { for (const unsafe of main.querySelectorAll( "embed, form, iframe, object, script, style, svg", )) { unsafe.remove(); } for (const element of main.querySelectorAll("*")) { for (const attribute of [...element.attributes]) { const name = attribute.name.toLowerCase(); if (name.startsWith("on") || name === "id" || name === "style") { element.removeAttribute(attribute.name); } } } for (const anchor of main.querySelectorAll("a")) { const href = anchor.getAttribute("href"); if (!href || anchor.getAttribute("aria-disabled") === "true") { anchor.remove(); continue; } const target = new URL( href, window.location.href, ); anchor.href = target.href; if (target.origin !== window.location.origin) { anchor.target = "_blank"; anchor.rel = "noreferrer"; } } for (const image of main.querySelectorAll("img")) { const source = image.getAttribute("src") || ""; if (source.startsWith("/public/") && source.endsWith(".png")) { image.src = source.replace(/\.png$/i, ".webp"); } image.loading = "lazy"; } return main; } function sanitizeToolDetail(main) { sanitizeFetchedMain(main); for (const textarea of main.querySelectorAll("textarea")) { const sample = document.createElement("pre"); sample.className = "jrpg-tool-source-sample"; sample.textContent = textarea.value || textarea.textContent; textarea.replaceWith(sample); } for (const inactive of main.querySelectorAll( "button, canvas, dialog, input, select, video, [hidden]", )) { inactive.remove(); } for (const owner of main.querySelectorAll( "zen-button, zen-checkbox, zen-field, zen-input, zen-textarea", )) { owner.replaceWith(...owner.childNodes); } for (const label of main.querySelectorAll("label[for]")) { label.removeAttribute("for"); } return main; } class MjjJrpgPreview extends HTMLElement { connectedCallback() { this._dialog = this.querySelector("dialog"); this._onDialogClose = () => { this._toolRequest = (this._toolRequest || 0) + 1; this.cleanupTool(); }; this._onClick = event => { const trigger = event.target.closest("[data-zen-trigger]"); if (trigger) { if (this.dataset.selection === "resume") void this.loadResume(); if (this.dataset.selection === "blog") { this.show("blog"); void this.loadBlogs(); } if (this.dataset.selection === "tools") { this.show("tools"); void this.openToolWorkspace(); } return; } const blogEntry = event.target.closest("[data-blog-entry]"); if (blogEntry) { void this.loadBlogDetail(blogEntry.dataset.blogUrl); return; } const latestBlog = event.target.closest("[data-latest-blog]"); if (latestBlog && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); this.querySelector("zen-dialog").open(); void this.loadBlogDetail(latestBlog.dataset.blogUrl); return; } const blogArchive = event.target.closest("[data-blog-archive]"); if (blogArchive) { event.preventDefault(); this.querySelector("zen-dialog").open(); this.show("blog"); void this.loadBlogs(); return; } const resumeModal = event.target.closest("[data-resume-modal]"); if (resumeModal) { event.preventDefault(); this.querySelector("zen-dialog").open(); this.show("resume"); void this.loadResume(); return; } const latestTool = event.target.closest("[data-latest-tool]"); if (latestTool && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); this.querySelector("zen-dialog").open(); void this.loadToolDetail(latestTool.dataset.toolUrl); } }; this.addEventListener("click", this._onClick); this._dialog.addEventListener("close", this._onDialogClose); this.show(this.dataset.selection || "resume"); } disconnectedCallback() { this.removeEventListener("click", this._onClick); this._dialog?.removeEventListener("close", this._onDialogClose); this.cleanupTool(); } show(selection) { this.cleanupTool(); const preview = PREVIEWS[selection] || PREVIEWS.resume; this.dataset.selection = selection; this.querySelector("[data-preview-title]").textContent = preview.title; this.querySelector("[data-preview-copy]").textContent = preview.copy; this.querySelector("[data-dialog-title]").textContent = preview.title; this.querySelector("[data-dialog-copy]").textContent = preview.copy; this.renderShowcase(preview.works); 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 blogBrowser = this.querySelector("[data-blog-browser]"); const toolBrowser = this.querySelector("[data-tool-browser]"); const isResume = selection === "resume"; const isBlog = selection === "blog"; const isTools = selection === "tools"; this._dialog.dataset.detailMode = isTools ? "tools" : "content"; resumeDossier.hidden = !isResume; resumeDownload.hidden = !isResume; this.querySelector("[data-dialog-actions]").hidden = !isResume; blogBrowser.hidden = !isBlog; toolBrowser.hidden = !isTools; this.querySelector("[data-dialog-copy]").hidden = isResume || isBlog || isTools; if (!isBlog) this._blogRequest = (this._blogRequest || 0) + 1; if (isBlog) { const status = this.querySelector("[data-blog-status]"); status.hidden = false; status.textContent = "Select a blog to inspect."; this.querySelector("[data-blog-content]").replaceChildren(); for (const button of this.querySelectorAll("[data-blog-entry]")) { button.setAttribute("aria-pressed", "false"); } void this.loadBlogs(); } if (!isTools) this._toolRequest = (this._toolRequest || 0) + 1; if (isTools) { const status = this.querySelector("[data-tool-status]"); status.hidden = false; status.textContent = "Select a tool to inspect."; this.querySelector("[data-tool-content]").replaceChildren(); void this.loadTools(); } } renderShowcase(works, latestBlogs = false, latestKind = null) { const showcase = this.querySelector("[data-work-showcase]"); showcase.replaceChildren(...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 (latestBlogs) { if (work.modal === "blog") { link.dataset.blogArchive = ""; } else { link.dataset.latestBlog = ""; link.dataset.blogUrl = work.url; } } if (work.modal === "resume") link.dataset.resumeModal = ""; if (latestKind === "tool") { link.dataset.latestTool = ""; link.dataset.toolUrl = work.url; const icon = document.createElement("zen-icon"); icon.setAttribute("name", TOOL_ICON_NAMES[work.url] || "settings"); link.prepend(icon); } 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; })); } 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"); sanitizeFetchedMain(resume); content.replaceChildren(...resume.childNodes); status.hidden = true; this._resumeLoaded = true; } catch (error) { status.textContent = `Unable to load resume: ${error.message}`; } finally { this._resumeLoading = false; } } async loadBlogs() { if (this._blogs) { if (this.dataset.selection === "blog") { this.renderShowcase([ ...this._blogs.slice(0, 4), { detail: "Full archive", label: "All blogs", modal: "blog", url: "/blog", }, ], true); } return true; } if (this._blogsLoading) return this._blogsLoading; const list = this.querySelector("[data-blog-list]"); this._blogsLoading = (async () => { const response = await fetch("/blog", { headers: { Accept: "text/html" }, }); if (!response.ok) { throw new Error(`Blog archive request failed (${response.status})`); } const documentCopy = new DOMParser().parseFromString( await response.text(), "text/html", ); this._blogs = [...documentCopy.querySelectorAll( 'main li a[href^="/blog/"]', )].map(anchor => ({ date: anchor.closest("li")?.querySelector("span")?.textContent.trim() || "Archive", detail: anchor.closest("li")?.querySelector("span")?.textContent.trim() || "Archive", label: anchor.textContent.trim(), url: anchor.getAttribute("href"), })); if (!this._blogs.length) throw new Error("No blog entries were found"); this.renderBlogList(); if (this.dataset.selection === "blog") { this.renderShowcase([ ...this._blogs.slice(0, 4), { detail: "Full archive", label: "All blogs", modal: "blog", url: "/blog", }, ], true); } return true; })().catch(error => { list.replaceChildren(); const item = document.createElement("li"); item.textContent = `Unable to load blogs: ${error.message}`; list.append(item); return false; }).finally(() => { this._blogsLoading = null; }); return this._blogsLoading; } renderBlogList() { const list = this.querySelector("[data-blog-list]"); list.replaceChildren(...this._blogs.map(blog => { const item = document.createElement("li"); const owner = document.createElement("zen-button"); const button = document.createElement("button"); const title = document.createElement("span"); const date = document.createElement("small"); owner.setAttribute("appearance", "plain"); owner.setAttribute("size", "sm"); button.type = "button"; button.dataset.blogEntry = ""; button.dataset.blogUrl = blog.url; title.textContent = blog.label; date.textContent = blog.date; button.append(title, date); owner.append(button); item.append(owner); return item; })); } async loadBlogDetail(url) { if (!await this.loadBlogs()) return; const blog = this._blogs.find(item => item.url === url); const status = this.querySelector("[data-blog-status]"); const content = this.querySelector("[data-blog-content]"); if (!blog) { status.hidden = false; status.textContent = "Unable to load blog: unknown entry."; return; } const request = (this._blogRequest || 0) + 1; this._blogRequest = request; status.hidden = false; status.textContent = `Loading ${blog.label}...`; content.replaceChildren(); this.querySelector("[data-dialog-title]").textContent = blog.label; const fullPage = this.querySelector("[data-preview-link]"); fullPage.href = blog.url; fullPage.setAttribute("aria-label", `Open ${blog.label}`); for (const button of this.querySelectorAll("[data-blog-entry]")) { button.setAttribute( "aria-pressed", String(button.dataset.blogUrl === blog.url), ); } try { const response = await fetch(blog.url, { headers: { Accept: "text/html" }, }); if (!response.ok) { throw new Error(`Blog request failed (${response.status})`); } const documentCopy = new DOMParser().parseFromString( await response.text(), "text/html", ); const article = documentCopy.querySelector("main"); if (!article) throw new Error("Blog content is unavailable"); sanitizeFetchedMain(article); if (request !== this._blogRequest || this.dataset.selection !== "blog") { return; } content.replaceChildren(...article.childNodes); status.hidden = true; } catch (error) { if (request !== this._blogRequest) return; status.hidden = false; status.textContent = `Unable to load blog: ${error.message}`; } } async loadTools() { if (this._tools) { if (this.dataset.selection === "tools") { this.renderShowcase(this._tools, false, "tool"); } return true; } if (this._toolsLoading) return this._toolsLoading; this._toolsLoading = (async () => { const response = await fetch("/tools", { headers: { Accept: "text/html" }, }); if (!response.ok) { throw new Error(`Tools request failed (${response.status})`); } const documentCopy = new DOMParser().parseFromString( await response.text(), "text/html", ); this._tools = [...documentCopy.querySelectorAll("main li a[href]")].map( anchor => ({ detail: anchor.getAttribute("href").startsWith("/notes") ? "Writing" : "Utility", label: anchor.textContent.trim(), url: anchor.getAttribute("href"), }), ); if (!this._tools.length) throw new Error("No tools were found"); if (this.dataset.selection === "tools") { this.renderShowcase(this._tools, false, "tool"); } return true; })().catch(error => { const status = this.querySelector("[data-tool-status]"); status.hidden = false; status.textContent = `Unable to load tools: ${error.message}`; return false; }).finally(() => { this._toolsLoading = null; }); return this._toolsLoading; } async openToolWorkspace() { if (!await this.loadTools() || this.dataset.selection !== "tools") return; await this.loadToolDetail(this._activeToolUrl || this._tools[0].url); } async loadToolDetail(url) { const request = (this._toolRequest || 0) + 1; this._toolRequest = request; if (!await this.loadTools()) return; if (request !== this._toolRequest || this.dataset.selection !== "tools") { return; } const tool = this._tools.find(item => item.url === url); const status = this.querySelector("[data-tool-status]"); const content = this.querySelector("[data-tool-content]"); if (!tool) { status.hidden = false; status.textContent = "Unable to load tool: unknown entry."; return; } this._activeToolUrl = tool.url; status.hidden = false; status.textContent = `Loading ${tool.label}...`; this.cleanupTool(); content.replaceChildren(); this.querySelector("[data-dialog-title]").textContent = tool.label; const fullPage = this.querySelector("[data-preview-link]"); fullPage.href = tool.url; fullPage.setAttribute("aria-label", `Open ${tool.label}`); if (tool.url === "/tools/markdown_to_html") { await this.renderMarkdownTool(content, status, request); return; } if (tool.url === "/tools/file_converter") { this.renderFileConverterTool(content, status); return; } if (tool.url === "/tools/hls_player") { await this.renderHlsTool(content, status, request); return; } if (tool.url === "/tools/latex_editor") { this.renderLatexTool(content, status); return; } if (tool.url === "/notes") { const heading = document.createElement("h1"); const description = document.createElement("p"); const privacy = document.createElement("p"); heading.textContent = "Personal Notes"; description.textContent = "A private browser-based writing workspace with rich-text editing and server-backed persistence."; privacy.textContent = "The interactive editor and authentication flow are available on the full Notes page."; content.replaceChildren( this.createReadonlyToolNote(), heading, description, privacy, ); status.hidden = true; return; } try { const response = await fetch(tool.url, { headers: { Accept: "text/html" }, }); if (!response.ok) { throw new Error(`Tool request failed (${response.status})`); } const documentCopy = new DOMParser().parseFromString( await response.text(), "text/html", ); const detail = documentCopy.querySelector("main, .container") || documentCopy.body; if (!detail) throw new Error("Tool content is unavailable"); sanitizeToolDetail(detail); if (request !== this._toolRequest || this.dataset.selection !== "tools") { return; } content.replaceChildren( this.createReadonlyToolNote(), ...detail.childNodes, ); status.hidden = true; } catch (error) { if (request !== this._toolRequest) return; status.hidden = false; status.textContent = `Unable to load tool: ${error.message}`; } } async renderMarkdownTool(content, status, request) { content.innerHTML = ` <section class="jrpg-functional-tool" data-functional-tool="markdown"> <div class="jrpg-tool-panes"> <section class="jrpg-tool-pane jrpg-tool-window"> <h2><zen-icon name="code"></zen-icon> Markdown source</h2> <zen-field appearance="plain" size="md"> <label for="jrpgMarkdownSource">Markdown</label> <textarea id="jrpgMarkdownSource" data-markdown-source></textarea> </zen-field> </section> <section class="jrpg-tool-pane jrpg-tool-window"> <h2><zen-icon name="check"></zen-icon> Converted HTML</h2> <div class="jrpg-markdown-output" data-markdown-output></div> </section> </div> </section> `; const source = content.querySelector("[data-markdown-source]"); const output = content.querySelector("[data-markdown-output]"); source.value = [ "# JRPG Markdown", "", "Edit this **Markdown** to update the converted panel.", "", "- Bazel-built WASM", "- Sanitized HTML output", ].join("\n"); status.hidden = false; status.textContent = "Loading the Markdown converter..."; try { this._markdownModule ||= import("/markdown_to_html_bin.js") .then(module => module.default()); const module = await this._markdownModule; if (request !== this._toolRequest || !source.isConnected) return; const convertMarkdown = module.cwrap( "markdown_to_html", "number", ["string"], ); const freeMarkdown = module.cwrap("markdown_free", null, ["number"]); const convert = () => { const pointer = convertMarkdown(source.value); const html = module.UTF8ToString(pointer); freeMarkdown(pointer); const documentCopy = new DOMParser().parseFromString(html, "text/html"); sanitizeFetchedMain(documentCopy.body); output.replaceChildren(...documentCopy.body.childNodes); }; source.addEventListener("input", convert); convert(); status.hidden = true; } catch (error) { this._markdownModule = null; if (request !== this._toolRequest) return; status.hidden = false; status.textContent = `Unable to start Markdown: ${error.message}`; } } renderFileConverterTool(content, status) { content.innerHTML = ` <section class="jrpg-functional-tool" data-functional-tool="converter"> <p class="jrpg-tool-status" data-converter-status> Choose a file in either panel. </p> <div class="jrpg-tool-panes"> <section class="jrpg-tool-pane jrpg-tool-window"> <h2><zen-icon name="file"></zen-icon> Image to WebP</h2> <zen-field appearance="plain" size="md"> <label for="jrpgImageInput">Image file</label> <input id="jrpgImageInput" data-converter-input="image" type="file" accept="image/*"> </zen-field> <zen-button appearance="plain" size="md"> <button type="button" data-convert-kind="image"> Convert image <zen-icon name="arrow-right"></zen-icon> </button> </zen-button> <a data-converter-download="image" hidden>Download WebP</a> </section> <section class="jrpg-tool-pane jrpg-tool-window"> <h2><zen-icon name="play"></zen-icon> Video to MP4</h2> <zen-field appearance="plain" size="md"> <label for="jrpgVideoInput">Video file</label> <input id="jrpgVideoInput" data-converter-input="video" type="file" accept="video/*"> </zen-field> <zen-button appearance="plain" size="md"> <button type="button" data-convert-kind="video"> Convert video <zen-icon name="arrow-right"></zen-icon> </button> </zen-button> <a data-converter-download="video" hidden>Download MP4</a> </section> </div> </section> `; const toolStatus = content.querySelector("[data-converter-status]"); let disposed = false; const downloads = new Set(); const discardDownload = url => { if (!url) return; downloads.delete(url); void fetch(url, { method: "DELETE", keepalive: true, }).then(response => { if (!response.ok && response.status !== 404) { console.warn(`Unable to discard converted file (${response.status})`); } }).catch(error => { console.warn(`Unable to discard converted file: ${error.message}`); }); }; const convert = async kind => { const input = content.querySelector(`[data-converter-input="${kind}"]`); const button = content.querySelector(`[data-convert-kind="${kind}"]`); const download = content.querySelector( `[data-converter-download="${kind}"]`, ); const file = input.files?.[0]; if (!file) { toolStatus.textContent = `Choose a ${kind} file first.`; toolStatus.dataset.state = "error"; return; } button.disabled = true; discardDownload(download.dataset.cleanupUrl); delete download.dataset.cleanupUrl; download.removeAttribute("href"); download.hidden = true; toolStatus.textContent = `Converting ${file.name}...`; toolStatus.dataset.state = "working"; try { const endpoint = kind === "image" ? "/api/convert/image-to-webp" : "/api/convert/video-to-mp4"; const response = await fetch(endpoint, { method: "POST", body: file, headers: { "Content-Type": file.type || "application/octet-stream" }, }); if (!response.ok) { throw new Error(await response.text() || `Conversion failed (${response.status})`); } const result = await response.json(); if (disposed || !download.isConnected) { discardDownload(result.download_url); return; } download.href = result.download_url; download.dataset.cleanupUrl = result.download_url; downloads.add(result.download_url); download.download = file.name.replace(/\.[^/.]+$/, "") + (kind === "image" ? ".webp" : ".mp4"); download.hidden = false; toolStatus.textContent = "Conversion complete."; toolStatus.dataset.state = "ready"; } catch (error) { if (!disposed) { toolStatus.textContent = `Conversion failed: ${error.message}`; toolStatus.dataset.state = "error"; } } finally { if (button.isConnected) button.disabled = false; } }; for (const button of content.querySelectorAll("[data-convert-kind]")) { button.addEventListener("click", () => { void convert(button.dataset.convertKind); }); } for (const download of content.querySelectorAll("[data-converter-download]")) { download.addEventListener("click", () => { downloads.delete(download.dataset.cleanupUrl); delete download.dataset.cleanupUrl; }); } this._toolCleanup = () => { disposed = true; for (const download of [...downloads]) discardDownload(download); }; status.hidden = true; } async renderHlsTool(content, status, request) { content.innerHTML = ` <section class="jrpg-functional-tool" data-functional-tool="hls"> <div class="jrpg-tool-panes"> <section class="jrpg-tool-pane jrpg-tool-window"> <h2><zen-icon name="file"></zen-icon> HLS source</h2> <zen-field appearance="plain" size="md"> <label for="jrpgHlsUrl">Playlist URL</label> <input id="jrpgHlsUrl" data-hls-url type="url" value="/public/hls-sample/h264-ts-stream.m3u8" > </zen-field> <div class="jrpg-tool-actions"> <zen-button appearance="plain" size="md"> <button type="button" data-hls-load> Load URL <zen-icon name="arrow-right"></zen-icon> </button> </zen-button> <zen-button appearance="plain" size="md"> <button type="button" data-hls-sample> Load sample <zen-icon name="play"></zen-icon> </button> </zen-button> </div> <p class="jrpg-tool-status" data-hls-status>Loading player engine...</p> </section> <section class="jrpg-tool-pane jrpg-tool-window"> <h2><zen-icon name="play"></zen-icon> Playback</h2> <video data-hls-video controls playsinline preload="metadata"></video> <dl class="jrpg-tool-details" data-hls-details hidden> <div><dt>Playback</dt><dd data-detail="mode">-</dd></div> <div><dt>Segments</dt><dd data-detail="segments">-</dd></div> <div><dt>Duration</dt><dd data-detail="duration">-</dd></div> <div><dt>Codec</dt><dd data-detail="codecs">-</dd></div> </dl> </section> </div> </section> `; try { await loadScript("/public/hls.min.js"); await loadScript("/tools/hls_player/hls-player.js"); if (request !== this._toolRequest || !content.isConnected) return; const video = content.querySelector("[data-hls-video]"); const playerStatus = content.querySelector("[data-hls-status]"); const details = content.querySelector("[data-hls-details]"); const url = content.querySelector("[data-hls-url]"); const player = new window.HlsPlayerModule.HlsPlayer(video, { statusElement: playerStatus, detailsElement: details, }); const load = () => { void player.load(url.value.trim()).catch(() => {}); }; content.querySelector("[data-hls-load]").addEventListener("click", load); content.querySelector("[data-hls-sample]").addEventListener("click", () => { url.value = "/public/hls-sample/h264-ts-stream.m3u8"; load(); }); playerStatus.textContent = "Ready to load a playlist."; playerStatus.dataset.state = "ready"; this._toolCleanup = () => player.destroy(); status.hidden = true; } catch (error) { if (request !== this._toolRequest) return; status.hidden = false; status.textContent = `Unable to start HLS: ${error.message}`; } } renderLatexTool(content, status) { content.innerHTML = ` <section class="jrpg-functional-tool" data-functional-tool="latex"> <p class="jrpg-tool-status" data-latex-status> Edit the source, then compile the PDF. </p> <div class="jrpg-tool-panes"> <section class="jrpg-tool-pane jrpg-tool-window"> <div class="jrpg-tool-pane-heading"> <h2><zen-icon name="code"></zen-icon> LaTeX source</h2> <span data-latex-size></span> </div> <zen-field appearance="plain" size="md"> <label for="jrpgLatexSource">document.tex</label> <textarea id="jrpgLatexSource" data-latex-source></textarea> </zen-field> <div class="jrpg-tool-actions"> <zen-button appearance="plain" size="md"> <button type="button" data-latex-compile> Compile PDF <zen-icon name="file"></zen-icon> </button> </zen-button> <zen-button appearance="plain" size="md" data-latex-download hidden> <a download="document.pdf"> Download PDF <zen-icon name="download"></zen-icon> </a> </zen-button> </div> </section> <section class="jrpg-tool-pane jrpg-tool-window"> <h2><zen-icon name="file"></zen-icon> PDF preview</h2> <iframe data-latex-preview title="Compiled LaTeX PDF preview"></iframe> <pre data-latex-diagnostics hidden></pre> </section> </div> </section> `; const source = content.querySelector("[data-latex-source]"); const compileButton = content.querySelector("[data-latex-compile]"); const toolStatus = content.querySelector("[data-latex-status]"); const size = content.querySelector("[data-latex-size]"); const preview = content.querySelector("[data-latex-preview]"); const diagnostics = content.querySelector("[data-latex-diagnostics]"); const downloadOwner = content.querySelector("[data-latex-download]"); const download = downloadOwner.querySelector("a"); source.value = String.raw`\documentclass[11pt]{article} \usepackage[margin=1in]{geometry} \title{JRPG LaTeX} \author{MrJuneJune} \begin{document} \maketitle This PDF was compiled from the cyberpunk tool modal. \end{document}`; let controller = null; let pdfUrl = null; let compileGeneration = 0; const clearPdf = () => { if (pdfUrl) URL.revokeObjectURL(pdfUrl); pdfUrl = null; preview.src = "about:blank"; preview.hidden = false; diagnostics.hidden = true; download.removeAttribute("href"); downloadOwner.hidden = true; }; const updateSize = () => { const bytes = new TextEncoder().encode(source.value).byteLength; size.textContent = `${bytes.toLocaleString()} / 65,536 bytes`; return bytes; }; const compile = async () => { const generation = ++compileGeneration; clearPdf(); const bytes = updateSize(); if (!source.value.trim() || bytes > 64 * 1024) { toolStatus.textContent = bytes > 64 * 1024 ? "Source exceeds 64 KiB." : "Write LaTeX before compiling."; toolStatus.dataset.state = "error"; return; } controller?.abort(); const requestController = new AbortController(); controller = requestController; compileButton.disabled = true; toolStatus.textContent = "Compiling on the server..."; toolStatus.dataset.state = "working"; try { const response = await fetch("/api/latex/render", { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, body: source.value, cache: "no-store", signal: requestController.signal, }); if (generation !== compileGeneration) return; if (!response.ok) { throw new Error(await response.text() || `Compilation failed (${response.status})`); } const blob = await response.blob(); if (generation !== compileGeneration) return; if (blob.type !== "application/pdf" || blob.size < 5) { throw new Error("The server returned an invalid PDF."); } if (pdfUrl) URL.revokeObjectURL(pdfUrl); pdfUrl = URL.createObjectURL(blob); preview.src = pdfUrl; preview.hidden = false; diagnostics.hidden = true; download.href = pdfUrl; downloadOwner.hidden = false; toolStatus.textContent = `PDF ready (${Math.ceil(blob.size / 1024)} KiB).`; toolStatus.dataset.state = "ready"; } catch (error) { if (error.name !== "AbortError" && generation === compileGeneration) { diagnostics.textContent = error.message; diagnostics.hidden = false; preview.hidden = true; toolStatus.textContent = "Compilation failed."; toolStatus.dataset.state = "error"; } } finally { if (controller === requestController) { controller = null; compileButton.disabled = false; } } }; source.addEventListener("input", () => { compileGeneration++; controller?.abort(); controller = null; compileButton.disabled = false; clearPdf(); updateSize(); toolStatus.textContent = "Changes ready to compile."; toolStatus.dataset.state = "working"; }); compileButton.addEventListener("click", () => void compile()); updateSize(); this._toolCleanup = () => { compileGeneration++; controller?.abort(); clearPdf(); }; status.hidden = true; } cleanupTool() { this._toolCleanup?.(); this._toolCleanup = null; } createReadonlyToolNote() { const introduction = document.createElement("p"); introduction.className = "jrpg-tool-readonly-note"; introduction.textContent = "Read-only preview. Open the full page to use this tool."; return introduction; } } /* ===================================================================== MjjConversationArchive — archive panel within the scene ===================================================================== */ class MjjConversationArchive extends HTMLElement { connectedCallback() { this._list = this.querySelector("[data-archive-list]"); this._status = this.querySelector("[data-archive-status]"); this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]"); this._loadMoreButton = this.querySelector("[data-archive-load-more]"); this._newButton = this.querySelector("[data-archive-new]"); this._closeButton = this.querySelector("[data-archive-close]"); this._deleteDialog = this.querySelector("[data-archive-delete-dialog]"); this._deleteNameEl = this.querySelector("[data-archive-delete-name]"); this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]"); this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]"); this._renameDialog = this.querySelector("[data-archive-rename-dialog]"); this._renameInput = this.querySelector("[data-archive-rename-input]"); this._renameForm = this.querySelector("[data-archive-rename-form]"); this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]"); this._pendingDeleteId = null; this._pendingDeleteTrigger = null; this._pendingRenameId = null; this._pendingRenameTrigger = null; this._closeButton?.addEventListener("click", () => { this.dispatchEvent(new CustomEvent("mjj-archive-toggle", { bubbles: true })); }); this._newButton?.addEventListener("click", () => { this.dispatchEvent(new CustomEvent("mjj-archive-new", { bubbles: true })); }); this._loadMoreButton?.addEventListener("click", () => { this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true })); }); this._deleteCancelButton?.addEventListener("click", () => { this._deleteDialog?.close(); const t = this._pendingDeleteTrigger; this._pendingDeleteId = null; this._pendingDeleteTrigger = null; t?.focus(); }); this._deleteConfirmButton?.addEventListener("click", () => { const id = this._pendingDeleteId; const trigger = this._pendingDeleteTrigger; this._deleteDialog?.close(); this._pendingDeleteId = null; this._pendingDeleteTrigger = null; if (id) { this.dispatchEvent(new CustomEvent("mjj-archive-delete", { bubbles: true, detail: { id, trigger }, })); } }); this._deleteDialog?.addEventListener("close", () => { const t = this._pendingDeleteTrigger; this._pendingDeleteId = null; this._pendingDeleteTrigger = null; t?.focus(); }); this._renameCancelButton?.addEventListener("click", () => { this._renameDialog?.close(); const t = this._pendingRenameTrigger; this._pendingRenameId = null; this._pendingRenameTrigger = null; t?.focus(); }); this._renameForm?.addEventListener("submit", event => { event.preventDefault(); const id = this._pendingRenameId; const trigger = this._pendingRenameTrigger; const newTitle = this._renameInput?.value.trim(); if (!id || !newTitle) return; this._renameDialog?.close(); this._pendingRenameId = null; this._pendingRenameTrigger = null; this.dispatchEvent(new CustomEvent("mjj-archive-rename", { bubbles: true, detail: { id, newTitle, trigger }, })); }); this._renameDialog?.addEventListener("close", () => { const t = this._pendingRenameTrigger; this._pendingRenameId = null; this._pendingRenameTrigger = null; t?.focus(); }); this._onListClick = event => { const openButton = event.target.closest("[data-conv-open]"); if (openButton) { const id = openButton.closest("[data-conv-item]")?._convId; if (id) { this.dispatchEvent(new CustomEvent("mjj-archive-open", { bubbles: true, detail: { id }, })); } return; } const renameButton = event.target.closest("[data-conv-rename]"); if (renameButton) { const item = renameButton.closest("[data-conv-item]"); if (item?._convId) { this._pendingRenameId = item._convId; this._pendingRenameTrigger = renameButton; if (this._renameInput) this._renameInput.value = item._convTitle || ""; this._renameDialog?.showModal(); requestAnimationFrame(() => { this._renameInput?.select(); }); } return; } const deleteButton = event.target.closest("[data-conv-delete]"); if (deleteButton) { const item = deleteButton.closest("[data-conv-item]"); if (item?._convId) { this._pendingDeleteId = item._convId; this._pendingDeleteTrigger = deleteButton; if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || ""; this._deleteDialog?.showModal(); requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); }); } return; } }; this._list?.addEventListener("click", this._onListClick); this._onListKeydown = event => { if (!["ArrowUp", "ArrowDown"].includes(event.key)) return; const items = [...(this._list?.querySelectorAll("[data-conv-open]") || [])]; const index = items.indexOf(document.activeElement); if (index < 0) return; event.preventDefault(); const next = event.key === "ArrowDown" ? items[Math.min(index + 1, items.length - 1)] : items[Math.max(index - 1, 0)]; next?.focus(); }; this._list?.addEventListener("keydown", this._onListKeydown); } disconnectedCallback() { this._list?.removeEventListener("click", this._onListClick); this._list?.removeEventListener("keydown", this._onListKeydown); } setConversations(conversations, cursor, append = false) { if (!this._list) return; if (!append) this._list.replaceChildren(); for (const conv of conversations) this._list.append(this._createItem(conv)); if (this._loadMoreOwner) this._loadMoreOwner.hidden = !cursor; if (!conversations.length && !append) { this._showStatus("No conversations yet. Start a new one!"); } else { this._hideStatus(); } } setCurrentId(id) { for (const button of (this._list?.querySelectorAll("[data-conv-open]") || [])) { const isCurrent = id && button.closest("[data-conv-item]")?._convId === id; button.setAttribute("aria-current", String(Boolean(isCurrent))); } } addConversation(conv) { if (!this._list) return; this._list.prepend(this._createItem(conv)); this._hideStatus(); } updateConversation(conv) { if (!this._list) return; for (const item of this._list.querySelectorAll("[data-conv-item]")) { if (item._convId === conv.id) { const titleEl = item.querySelector("[data-conv-title]"); const metaEl = item.querySelector("[data-conv-meta]"); if (titleEl) titleEl.textContent = conv.title; if (metaEl) metaEl.textContent = this._formatMeta(conv); item._convTitle = conv.title; return; } } this.addConversation(conv); } removeConversation(id) { for (const item of (this._list?.querySelectorAll("[data-conv-item]") || [])) { if (item._convId === id) { item.remove(); break; } } if (!this._list?.querySelector("[data-conv-item]")) { this._showStatus("No conversations yet. Start a new one!"); } } setLoading(loading) { if (loading) { this._showStatus("Loading conversations..."); } else if (!this._list?.querySelector("[data-conv-item]")) { this._showStatus("No conversations yet. Start a new one!"); } else { this._hideStatus(); } } setError(message) { this._showStatus(message); if (this._status) this._status.dataset.state = "error"; } setLoadMoreDisabled(disabled) { if (this._loadMoreButton) this._loadMoreButton.disabled = disabled; } /* Disable/enable destructive archive controls while a stream is active. */ setStreamActive(active) { const controls = this._list?.querySelectorAll( "[data-conv-delete], [data-conv-rename]", ) || []; for (const el of controls) el.disabled = active; if (this._newButton) this._newButton.disabled = active; if (this._deleteConfirmButton) this._deleteConfirmButton.disabled = active; this.dataset.streamActive = active ? "true" : ""; if (!active) delete this.dataset.streamActive; } /* Return the open-button of the adjacent surviving item, or New button. */ adjacentItemOrNewButton(id) { const items = [...(this._list?.querySelectorAll("[data-conv-item]") || [])]; const idx = items.findIndex(el => el._convId === id); if (idx < 0) return this._newButton; const sibling = items[idx + 1] || items[idx - 1]; return sibling?.querySelector("[data-conv-open]") || this._newButton; } showLegacyClaim(statusMessage = null, showButton = true) { if (!this._status) return; this._status.hidden = false; delete this._status.dataset.state; this._status.textContent = ""; const msg = document.createElement("span"); msg.textContent = statusMessage ?? "You have a prior conversation. "; this._status.append(msg); if (showButton) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "Claim it"; btn.dataset.archiveClaim = ""; btn.addEventListener("click", () => { this.dispatchEvent(new CustomEvent("mjj-archive-claim", { bubbles: true })); }); this._status.append(btn); } } _showStatus(text) { if (this._status) { this._status.textContent = text; this._status.hidden = false; delete this._status.dataset.state; } } _hideStatus() { if (this._status) this._status.hidden = true; } _createItem(conv) { const item = document.createElement("li"); item.dataset.convItem = ""; item._convId = conv.id; item._convTitle = conv.title; const openButton = document.createElement("button"); openButton.type = "button"; openButton.dataset.convOpen = ""; openButton.setAttribute("aria-current", "false"); const titleEl = document.createElement("span"); titleEl.dataset.convTitle = ""; titleEl.textContent = conv.title; const metaEl = document.createElement("small"); metaEl.dataset.convMeta = ""; metaEl.textContent = this._formatMeta(conv); openButton.append(titleEl, metaEl); const actions = document.createElement("div"); actions.className = "jrpg-archive-item-actions"; const renameButton = document.createElement("button"); renameButton.type = "button"; renameButton.dataset.convRename = ""; renameButton.setAttribute("aria-label", `Rename "${conv.title}"`); renameButton.textContent = "Rename"; const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.dataset.convDelete = ""; deleteButton.setAttribute("aria-label", `Delete "${conv.title}"`); deleteButton.textContent = "Delete"; actions.append(renameButton, deleteButton); item.append(openButton, actions); return item; } _formatMeta(conv) { const count = conv.turn_count ?? 0; const turns = count === 1 ? "1 turn" : `${count} turns`; if (conv.last_message_preview) { return `${turns} · ${conv.last_message_preview.slice(0, 28)}`; } return turns; } } class MjjJrpgShell extends HTMLElement { connectedCallback() { this._mobileMenuQuery = window.matchMedia( "(max-width: 52rem) and (orientation: portrait)", ); this._mobileDestinationOwner = this.querySelector( "[data-mobile-destination-owner]", ); this._mobileDestinationDialog = this.querySelector( "[data-mobile-destination-dialog]", ); this._mobileDestinationContent = this.querySelector( "[data-mobile-destination-content]", ); this._mobileDestinationTitle = this.querySelector( "[data-mobile-destination-title]", ); this._mobileDestinationBackOwner = this.querySelector( "[data-mobile-destination-back-owner]", ); this._mobileDestinationBack = this.querySelector( "[data-mobile-destination-back]", ); this._utility = this.querySelector(".jrpg-utility"); this._menu = this.querySelector("mjj-jrpg-menu"); this._utilityPlaceholder = document.createComment("jrpg-utility"); this._menuPlaceholder = document.createComment("jrpg-menu"); this._utility?.before(this._utilityPlaceholder); this._menu?.before(this._menuPlaceholder); this._syncMobileLayout = () => { if (!this._utility || !this._menu || !this._mobileDestinationContent) { return; } if (this._mobileMenuQuery.matches) { this._mobileDestinationContent.append(this._menu, this._utility); this.showMobileMenu(); return; } this._mobileDestinationOwner?.close(); this._utilityPlaceholder.after(this._utility); this._menuPlaceholder.after(this._menu); this._utility.hidden = false; this._menu.hidden = false; }; this._onMobileMenuMediaChange = () => this._syncMobileLayout(); this._onMobileDestinationBack = () => this.showMobileMenu(); this._onMobileDestinationClose = () => this.showMobileMenu(); this._mobileDestinationBack?.addEventListener( "click", this._onMobileDestinationBack, ); this._mobileDestinationDialog?.addEventListener( "close", this._onMobileDestinationClose, ); this._mobileMenuQuery.addEventListener( "change", this._onMobileMenuMediaChange, ); this._syncMobileLayout(); } isMobileDestinationModal() { return Boolean(this._mobileMenuQuery?.matches); } isMobileDestinationOpen() { return this.isMobileDestinationModal() && Boolean(this._mobileDestinationDialog?.open); } showMobileMenu() { if (!this.isMobileDestinationModal() || !this._menu || !this._utility) { return; } this._menu.hidden = false; this._utility.hidden = true; if (this._mobileDestinationTitle) { this._mobileDestinationTitle.textContent = "Menu"; } if (this._mobileDestinationBackOwner) { this._mobileDestinationBackOwner.hidden = true; } } showMobileDestination(selection) { if (!this.isMobileDestinationModal() || !this._menu || !this._utility) { return; } const labels = { blog: "Blogs", conversations: "Conversations", resume: "Resume", tools: "Tools", }; this._menu.hidden = true; this._utility.hidden = false; if (this._mobileDestinationTitle) { this._mobileDestinationTitle.textContent = labels[selection] || "Destination"; } if (this._mobileDestinationBackOwner) { this._mobileDestinationBackOwner.hidden = false; } } closeMobileDestinations() { if (this.isMobileDestinationModal()) { this._mobileDestinationOwner?.close(); } } disconnectedCallback() { this._mobileDestinationBack?.removeEventListener( "click", this._onMobileDestinationBack, ); this._mobileDestinationDialog?.removeEventListener( "close", this._onMobileDestinationClose, ); this._mobileMenuQuery?.removeEventListener( "change", this._onMobileMenuMediaChange, ); } } for (const [name, constructor] of Object.entries({ "mjj-conversation-archive": MjjConversationArchive, "mjj-jrpg-character": MjjJrpgCharacter, "mjj-jrpg-chat": MjjJrpgChat, "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, credentials: "same-origin", 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(); } /* Session state for CSRF: bootstrapped once, refreshed at most once on 401/403 */ let _csrfToken = null; let _sessionData = null; let _sessionRefreshInFlight = null; /* single-flight guard */ function _principalFingerprint(data) { if (!data) return null; if (data.kind === "user") return `user:${data.username || ""}:${data.role || ""}`; if (data.kind === "guest") return `guest:${data.csrfToken || ""}`; return null; } async function _fetchSession() { try { const response = await fetch("/api/auth/session", { credentials: "same-origin", }); if (!response.ok) return null; const data = await response.json().catch(() => null); if (!data) return null; _sessionData = data; return data.csrfToken || null; } catch { return null; } } /* Single-flight session refresh: dedupe concurrent callers. */ async function _refreshSessionOnce() { if (_sessionRefreshInFlight) return _sessionRefreshInFlight; _sessionRefreshInFlight = (async () => { const prev = _principalFingerprint(_sessionData); const token = await _fetchSession(); const next = _principalFingerprint(_sessionData); return { token, prev, next }; })(); try { return await _sessionRefreshInFlight; } finally { _sessionRefreshInFlight = null; } } /* * Perform a state-changing (mutating) fetch with CSRF token and same-origin * credentials. On 401 or CSRF-specific 403 (csrf_invalid), refreshes the * session once and retries — but only if the refreshed principal matches the * original. If the user becomes a guest or a different account, rejects and * triggers the session-changed callback. */ let _sessionRefreshCallback = null; async function mutatingFetch(url, options = {}) { const buildHeaders = () => ({ ...(options.headers || {}), Origin: window.location.origin, ...(_csrfToken ? { "X-CSRF-Token": _csrfToken } : {}), }); const response = await fetch(url, { ...options, credentials: "same-origin", headers: buildHeaders(), }); const shouldRefresh = response.status === 401 || (response.status === 403 && await (async () => { try { const clone = response.clone(); const body = await clone.json(); return body?.error?.code === "csrf_invalid"; } catch { return false; } })()); if (shouldRefresh) { const prevFingerprint = _principalFingerprint(_sessionData); const { token, next } = await _refreshSessionOnce(); _csrfToken = token; if (next !== prevFingerprint) { if (_sessionRefreshCallback) _sessionRefreshCallback(); const err = Object.assign(new Error("Session changed"), { status: 401 }); throw err; } const retried = await fetch(url, { ...options, credentials: "same-origin", headers: buildHeaders(), }); if (retried.status === 401 && _sessionRefreshCallback) { _sessionRefreshCallback(); } return retried; } return response; } 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); } /* ===================================================================== Account slot rendering ===================================================================== */ function renderAccountSlot(slotEl, sessionData) { if (!slotEl) return; slotEl.textContent = ""; if (!sessionData || sessionData.kind === "guest") { const owner = document.createElement("zen-button"); const btn = document.createElement("button"); owner.setAttribute("appearance", "plain"); owner.setAttribute("size", "xs"); btn.type = "button"; btn.dataset.loginOpen = ""; btn.textContent = "LOGIN"; owner.append(btn); slotEl.setAttribute("aria-label", "Not signed in"); slotEl.append(owner); return; } const username = (sessionData.username || "").slice(0, 8).toUpperCase(); if (sessionData.mustChangePassword) { const link = document.createElement("a"); link.href = "/account/password"; link.textContent = username ? `${username}\u26A0` : "PASSWD"; link.title = "Password change required"; slotEl.setAttribute("aria-label", `${sessionData.username}: password change required`); slotEl.append(link); return; } const nameSpan = document.createElement("span"); nameSpan.dataset.accountUser = ""; nameSpan.textContent = username; slotEl.append(nameSpan); if (sessionData.role === "admin") { const adminLink = document.createElement("a"); adminLink.href = "/admin/users"; adminLink.textContent = "\u00A0ADM"; slotEl.append(adminLink); } const logoutBtn = document.createElement("button"); logoutBtn.type = "button"; logoutBtn.textContent = "\u00A0LGOUT"; logoutBtn.addEventListener("click", () => { logoutBtn.disabled = true; mutatingFetch("/api/auth/logout", { method: "POST" }).then(response => { if (!response.ok) throw new Error(`Logout failed (${response.status})`); window.dispatchEvent(new CustomEvent("mjj-logout")); }).catch(() => { logoutBtn.disabled = false; }); }); slotEl.append(logoutBtn); slotEl.setAttribute("aria-label", `Signed in as ${sessionData.username}`); } /* ===================================================================== Quota display helpers ===================================================================== */ function formatQuotaText(quota) { if (!quota) return null; const turns = quota.turnsRemaining ?? 0; const tokens = quota.outputTokensRemaining ?? 0; if (turns <= 0) { const resetAt = quota.resetsAt ? new Date(quota.resetsAt * 1000) : null; const resetStr = resetAt ? ` Resets ${resetAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""; return { text: `Quota exhausted.${resetStr}`, state: "exhausted" }; } if (turns <= 2) { return { text: `${turns} turn${turns === 1 ? "" : "s"} remaining (${tokens.toLocaleString()} tokens)`, state: "warning", }; } return { text: `${turns} turns remaining (${tokens.toLocaleString()} tokens)`, state: "ok", }; } function updateQuotaDisplay(quotaEl, quota) { if (!quotaEl) return; if (!quota) { quotaEl.hidden = true; return; } const result = formatQuotaText(quota); /* Only show the bar for low or exhausted quota; hide when turns are plentiful */ if (!result || result.state === "ok") { quotaEl.hidden = true; return; } quotaEl.textContent = result.text; if (result.state === "exhausted") { quotaEl.dataset.state = "exhausted"; } else if (result.state === "warning") { quotaEl.dataset.state = "warning"; } else { delete quotaEl.dataset.state; } quotaEl.hidden = false; } /* ===================================================================== Conversation archive helpers ===================================================================== */ async function fetchConversationList(cursor) { const url = cursor ? `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}&cursor=${encodeURIComponent(cursor)}` : `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}`; const response = await fetch(url, { credentials: "same-origin" }); if (!response.ok) { let message = `Failed to load conversations (${response.status})`; try { const body = await response.json(); message = body.error?.message || message; } catch { /* keep status message */ } throw new Error(message); } return response.json(); } 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); } /* Bootstrap session to obtain CSRF token and session data */ _csrfToken = await _fetchSession(); 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-composer"); const preview = shell?.querySelector("mjj-jrpg-preview"); const archive = shell?.querySelector("mjj-conversation-archive"); const accountSlot = shell?.querySelector("[data-frame-account]"); const quotaEl = shell?.querySelector("[data-quota]"); const frameNetwork = shell?.querySelector("[data-frame-network]"); const frameUptime = shell?.querySelector("[data-frame-uptime]"); const minimizeButton = shell?.querySelector("[data-frame-minimize]"); const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); let characterTimer = 0; let currentConversationId = null; let currentPanel = "resume"; let archiveCursor = null; let activeController = null; let archiveLoadInFlight = false; /* serialize pagination requests */ let openSeq = 0; /* sequence guard for conversation open */ let popSeq = 0; /* sequence guard for popstate */ let _pendingPopState = null; /* queued popstate received during active stream */ let _claimInFlight = false; /* guard against duplicate concurrent claims */ let legacyClaimId = sessionStorage.getItem(CONVERSATION_LEGACY_KEY); let principalEpoch = 0; /* rejects responses from a prior owner */ /* Dedupe conversation IDs: set of IDs currently rendered in archive */ const _renderedIds = new Set(); const invalidatePrincipalRequests = () => { principalEpoch += 1; openSeq += 1; popSeq += 1; archiveCursor = null; }; chat?.setGreeting(greetingForSession(_sessionData), true); /* Disable composer until fully initialized */ composer?.setDisabled(true); const setFrameStatus = (label, state, title = "") => { if (frameNetwork) { frameNetwork.textContent = label; frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`); frameNetwork.dataset.state = state; frameNetwork.title = title; } }; /* Bootstrap failure: keep controls disabled and expose retry */ if (!_csrfToken) { setFrameStatus("OFFLINE", "offline", "Bootstrap failed"); archive?.setError("Connection failed. Reload to retry."); return; } setFrameStatus("ONLINE", "online"); const startedAt = Date.now(); const updateUptime = () => { const elapsed = Math.floor((Date.now() - startedAt) / 1000); const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0"); const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0"); const seconds = String(elapsed % 60).padStart(2, "0"); if (frameUptime) { frameUptime.textContent = `${hours}:${minutes}:${seconds}`; } }; updateUptime(); window.setInterval(updateUptime, 1000); minimizeButton?.addEventListener("click", () => { const minimized = shell.dataset.minimized !== "true"; shell.dataset.minimized = String(minimized); minimizeButton.setAttribute("aria-pressed", String(minimized)); minimizeButton.setAttribute( "aria-label", minimized ? "Restore interface" : "Minimize interface", ); }); fullscreenButton?.addEventListener("click", async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await shell.requestFullscreen(); } catch (error) { setFrameStatus("DENIED", "offline", error.message); } }); document.addEventListener("fullscreenchange", () => { fullscreenButton?.setAttribute( "aria-pressed", String(Boolean(document.fullscreenElement)), ); }); /* Render account slot */ renderAccountSlot(accountSlot, _sessionData); /* ---- Login modal ---- */ const loginDialogOwner = shell?.querySelector("[data-login-dialog-owner]"); const loginDialog = loginDialogOwner?.querySelector("[data-login-dialog]"); const loginForm = loginDialog?.querySelector("[data-login-form]"); const loginSubmitBtn = loginDialog?.querySelector("[data-login-submit]"); const loginCancelBtn = loginDialog?.querySelector("[data-login-cancel]"); const loginErrorEl = loginDialog?.querySelector("[data-login-error]"); const loginUsernameInput = loginDialog?.querySelector("#jrpg-login-username"); const loginPasswordInput = loginDialog?.querySelector("#jrpg-login-password"); let _loginTrigger = null; const setAccountActionsDisabled = disabled => { for (const control of accountSlot?.querySelectorAll("button") || []) { control.disabled = disabled; } }; const _clearLoginError = () => { if (loginErrorEl) { loginErrorEl.textContent = ""; loginErrorEl.hidden = true; } }; const _showLoginError = (message) => { if (!loginErrorEl) return; loginErrorEl.textContent = message; loginErrorEl.hidden = false; }; const openLoginModal = (triggerEl) => { if (!loginDialogOwner || !loginDialog) return; _loginTrigger = triggerEl || null; _clearLoginError(); loginForm?.reset(); loginDialogOwner.open(); requestAnimationFrame(() => { loginUsernameInput?.focus(); }); }; /* Delegate clicks on data-login-open in the account slot */ accountSlot?.addEventListener("click", event => { const btn = event.target.closest("[data-login-open]"); if (!btn || activeController) return; openLoginModal(btn); }); /* Cancel button */ loginCancelBtn?.addEventListener("click", () => { loginDialog?.close(); }); /* Cleanup on any close (Escape, X button, cancel) */ loginDialog?.addEventListener("close", () => { if (loginPasswordInput) loginPasswordInput.value = ""; _clearLoginError(); const trigger = _loginTrigger; _loginTrigger = null; requestAnimationFrame(() => { trigger?.focus(); }); }); /* Login form submission */ loginForm?.addEventListener("submit", async event => { event.preventDefault(); if (loginSubmitBtn?.disabled) return; const username = loginUsernameInput?.value.trim() || ""; const password = loginPasswordInput?.value || ""; if (!username || !password) { _showLoginError("Please enter your username and password."); return; } if (loginSubmitBtn) loginSubmitBtn.disabled = true; _clearLoginError(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", "Origin": window.location.origin, }, credentials: "same-origin", body: JSON.stringify({ username, password, csrfToken: _csrfToken }), }); if (loginPasswordInput) loginPasswordInput.value = ""; if (response.status === 429) { _showLoginError("Too many attempts. Please try again later."); return; } if (!response.ok) { _showLoginError("Invalid username or password."); return; } let data = await response.json().catch(() => null); if (!data) { _csrfToken = await _fetchSession(); if (_sessionData?.kind !== "user") { loginDialog?.close(); window.location.reload(); return; } data = _sessionData; } invalidatePrincipalRequests(); loginDialog?.close(); _sessionData = { kind: "user", username: data.username || username, role: data.role || "member", mustChangePassword: Boolean(data.mustChangePassword), csrfToken: data.csrfToken, quota: null, }; _csrfToken = data.csrfToken || null; if (data.mustChangePassword) { window.location.href = "/account/password"; return; } /* Commit the returned principal before optional UI refresh work. */ renderAccountSlot(accountSlot, _sessionData); updateQuotaDisplay(quotaEl, _sessionData?.quota || null); composer?.setDisabled(false); /* Clear conversation state — guest convs may transfer to user */ currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); legacyClaimId = null; _replaceUrlState(currentPanel, null); chat?.setGreeting(greetingForSession(_sessionData), true); try { await refreshArchiveFromScratch(); } catch (error) { archive?.setError(`Signed in, but archive refresh failed: ${error.message}`); } requestAnimationFrame(() => { accountSlot?.querySelector("button, a")?.focus(); }); } catch { if (loginPasswordInput) loginPasswordInput.value = ""; _showLoginError("Sign-in failed. Please try again."); } finally { if (loginSubmitBtn) loginSubmitBtn.disabled = false; } }); /* Handle logout */ _sessionRefreshCallback = () => { /* Account changed or session lost — clear active state */ invalidatePrincipalRequests(); if (activeController) { activeController.abort(); activeController = null; } currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); legacyClaimId = null; _replaceUrlState(currentPanel, null); chat?.setGreeting(greetingForSession(_sessionData), true); _renderedIds.clear(); archiveCursor = null; archive?.setConversations([], null); renderAccountSlot(accountSlot, _sessionData); archive?.setError("Session changed. Loading conversations..."); void refreshArchiveFromScratch(); }; window.addEventListener("mjj-logout", () => { invalidatePrincipalRequests(); if (activeController) { activeController.abort(); activeController = null; } archive?.setStreamActive(false); currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); legacyClaimId = null; _replaceUrlState(currentPanel, null); chat?.setGreeting(ROLE_GREETINGS.guest, true); _renderedIds.clear(); archiveCursor = null; archive?.setConversations([], null); void refreshArchiveFromScratch(); void (async () => { _csrfToken = await _fetchSession(); renderAccountSlot(accountSlot, _sessionData); updateQuotaDisplay(quotaEl, _sessionData?.quota || null); if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) { composer?.setDisabled(true); } else { composer?.setDisabled(false); } })(); }); /* Session quota display */ if (_sessionData?.kind === "guest") { updateQuotaDisplay(quotaEl, _sessionData.quota || null); } /* Disable composer for forced-password-change */ const isForced = _sessionData?.kind === "user" && _sessionData?.mustChangePassword; /* ---- URL state helpers ---- */ const _buildStateUrl = (panel, convId) => { const url = new URL(window.location.href); if (panel && VALID_PANELS.includes(panel)) { url.searchParams.set("panel", panel); } else { url.searchParams.delete("panel"); } if (convId && UUID_REGEX.test(convId)) { url.searchParams.set("conversation", convId); } else { url.searchParams.delete("conversation"); } return url; }; const _pushUrlState = (panel, convId) => { history.pushState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId)); }; const _replaceUrlState = (panel, convId) => { history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId)); }; /* ---- Panel selection: shows archive or preview in utility ---- */ const utilityEl = shell?.querySelector(".jrpg-utility"); const previewContent = preview?.querySelector("[data-preview-content]"); const selectPanel = (panel, pushHistory = true) => { const validPanel = VALID_PANELS.includes(panel) ? panel : "resume"; currentPanel = validPanel; for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) { btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel)); } if (validPanel === "conversations") { if (archive) archive.hidden = false; if (previewContent) previewContent.hidden = true; if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations"); } else { if (archive) archive.hidden = true; if (previewContent) previewContent.hidden = false; preview?.show(validPanel); if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination"); } if (pushHistory) _pushUrlState(validPanel, currentConversationId); }; /* ---- Archive close button: navigate back to resume panel ---- */ shell?.addEventListener("mjj-archive-toggle", () => { if (shell?.isMobileDestinationModal()) { shell.showMobileMenu(); return; } selectPanel("resume"); }); shell?.addEventListener("mjj-archive-new", () => { if (activeController) return; currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); _pushUrlState(currentPanel, null); chat?.setGreeting(greetingForSession(_sessionData), true); archive?.setCurrentId(null); shell?.closeMobileDestinations(); composer?.querySelector("textarea")?.focus(); }); /* ---- Popstate: re-apply panel and conversation on back/forward ---- */ /* _applyPopState always increments openSeq so any pending archive-open fetch (including a transition to no conversation) cannot overwrite the state the user navigated to (Fix 2). */ const _applyPopState = async (seq, popPanel, popConvId) => { const localSeq = ++openSeq; const epoch = principalEpoch; selectPanel(popPanel, false); if (shell?.isMobileDestinationOpen()) { shell.showMobileDestination(popPanel); } if (popConvId && popConvId !== currentConversationId) { try { const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`); if (seq !== popSeq || localSeq !== openSeq || epoch !== principalEpoch) return; currentConversationId = popConvId; sessionStorage.setItem(CONVERSATION_STORAGE_KEY, popConvId); chat?.replaceMessages(conversation.turns); archive?.setCurrentId(popConvId); } catch { if (seq !== popSeq || localSeq !== openSeq || epoch !== principalEpoch) return; currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); chat?.setGreeting(greetingForSession(_sessionData), true); archive?.setCurrentId(null); history.replaceState( { panel: popPanel, conversation: null }, "", _buildStateUrl(popPanel, null), ); } } else if (!popConvId && currentConversationId) { currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); chat?.setGreeting(greetingForSession(_sessionData), true); archive?.setCurrentId(null); } }; /* When popstate fires while a stream is active the URL has already changed. Queue the parsed destination, abort the stream, and apply exactly once in the stream's finally block so URL and UI always converge (Fix 1). */ window.addEventListener("popstate", async event => { const seq = ++popSeq; ++openSeq; const state = event.state || {}; const popPanel = VALID_PANELS.includes(state.panel) ? state.panel : "resume"; const popConvId = (state.conversation && UUID_REGEX.test(state.conversation)) ? state.conversation : null; if (activeController) { _pendingPopState = { seq, popPanel, popConvId }; activeController.abort(); return; } await _applyPopState(seq, popPanel, popConvId); }); shell?.addEventListener("mjj-archive-rename", async event => { const { id, newTitle, trigger } = event.detail; if (activeController) { trigger?.focus(); return; } try { const response = await mutatingFetch( `/api/conversations/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newTitle }), }, ); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message || `Rename failed (${response.status})`); } archive?.updateConversation({ id, title: newTitle, turn_count: null }); } catch (error) { archive?.setError(`Rename failed: ${error.message}`); } finally { trigger?.focus(); } }); shell?.addEventListener("mjj-archive-open", async event => { const { id } = event.detail; if (id === currentConversationId) return; if (activeController) return; const seq = ++openSeq; const epoch = principalEpoch; try { const conversation = await requestJson(`/api/conversations/${encodeURIComponent(id)}`); if (seq !== openSeq || epoch !== principalEpoch) return; currentConversationId = id; sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id); _pushUrlState(currentPanel, id); chat?.replaceMessages(conversation.turns); archive?.setCurrentId(id); shell?.closeMobileDestinations(); } catch (error) { if (seq !== openSeq || epoch !== principalEpoch) return; archive?.setError(`Unable to open: ${error.message}`); } }); shell?.addEventListener("mjj-archive-delete", async event => { const { id, trigger } = event.detail; if (activeController) { trigger?.focus(); return; } /* block while streaming */ try { const response = await mutatingFetch( `/api/conversations/${encodeURIComponent(id)}`, { method: "DELETE" }, ); if (response.ok || response.status === 404) { /* Focus adjacent archive item or New button before removing the node */ const focusTarget = archive?.adjacentItemOrNewButton(id); _renderedIds.delete(id); archive?.removeConversation(id); if (id === currentConversationId) { currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); _replaceUrlState(currentPanel, null); chat?.setGreeting(greetingForSession(_sessionData), true); } /* Focus surviving element — never the detached trigger */ requestAnimationFrame(() => { (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus(); }); } else { const body = await response.json().catch(() => ({})); archive?.setError(`Delete failed: ${body.error?.message || response.status}`); } } catch (error) { archive?.setError(`Delete failed: ${error.message}`); /* On error, the trigger is still attached — focus it */ trigger?.focus(); } }); shell?.addEventListener("mjj-archive-load-more", async () => { if (!archiveCursor || archiveLoadInFlight) return; archiveLoadInFlight = true; const epoch = principalEpoch; archive?.setLoadMoreDisabled(true); try { const data = await fetchConversationList(archiveCursor); if (epoch !== principalEpoch) return; archiveCursor = data.cursor || null; const newConvs = (data.conversations || []).filter(c => !_renderedIds.has(c.id)); newConvs.forEach(c => _renderedIds.add(c.id)); archive?.setConversations(newConvs, archiveCursor, true); } catch (error) { archive?.setError(`Load failed: ${error.message}`); } finally { archiveLoadInFlight = false; archive?.setLoadMoreDisabled(false); } }); shell?.addEventListener("mjj-archive-claim", async () => { const legacyId = legacyClaimId || sessionStorage.getItem(CONVERSATION_LEGACY_KEY); if (!legacyId || _sessionData?.kind !== "user" || _claimInFlight) return; _claimInFlight = true; archive?.showLegacyClaim("Claiming prior conversation...", false); let _claimOutcome = null; try { const response = await mutatingFetch( "/api/conversations/claim", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversationId: legacyId }), }, ); if (response.ok) { sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); legacyClaimId = null; _claimOutcome = "success"; } else if (response.status === 409) { /* Definitive non-legacy conflict: clear key, no retry needed */ sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); legacyClaimId = null; _claimOutcome = "conflict"; } else { /* Retryable (401, 403, 429, 5xx): retain key */ sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId); legacyClaimId = legacyId; _claimOutcome = "retry"; } } catch { /* Network error: retain key */ sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId); legacyClaimId = legacyId; _claimOutcome = "network-error"; } finally { /* Reset guard BEFORE re-rendering so the retry button is immediately usable */ _claimInFlight = false; if (_claimOutcome === "success") { void refreshArchiveFromScratch(); } else if (_claimOutcome === "conflict") { archive?.showLegacyClaim("Prior conversation already claimed.", false); } else if (_claimOutcome === "retry") { selectPanel("conversations", false); archive?.showLegacyClaim("Claim failed. Try again. "); } else if (_claimOutcome === "network-error") { selectPanel("conversations", false); archive?.showLegacyClaim("Claim failed. Check connection. "); } } }); async function refreshArchiveFromScratch() { if (!archive) return; const epoch = principalEpoch; archive.setLoading(true); _renderedIds.clear(); try { const data = await fetchConversationList(null); if (epoch !== principalEpoch) return; archiveCursor = data.cursor || null; const convs = data.conversations || []; convs.forEach(c => _renderedIds.add(c.id)); archive.setConversations(convs, archiveCursor); archive.setCurrentId(currentConversationId); } catch (error) { archive.setError(`Archive error: ${error.message}`); } } async function refreshArchiveHead() { if (!archive) return; const epoch = principalEpoch; try { const data = await fetchConversationList(null); if (epoch !== principalEpoch) return; const fresh = data.conversations || []; for (const conv of fresh) archive.updateConversation(conv); if (fresh.length > 0) archive.setCurrentId(currentConversationId); } catch { /* silent refresh failure */ } } /* ---- Parse and validate URL params at startup ---- */ /* Track whether ?conversation was explicitly present (even if malformed) so we never migrate sessionStorage when the param was given but invalid. */ let _convParamWasExplicit = false; { const initParams = new URLSearchParams(window.location.search); const rawPanel = initParams.get("panel"); const rawConvId = initParams.get("conversation"); _convParamWasExplicit = rawConvId !== null; const validPanel = VALID_PANELS.includes(rawPanel) ? rawPanel : null; const validConvId = (rawConvId && UUID_REGEX.test(rawConvId)) ? rawConvId : null; /* Remove invalid params with replaceState */ if ((rawPanel && !validPanel) || (rawConvId && !validConvId)) { const normalUrl = new URL(window.location.href); if (!validPanel) normalUrl.searchParams.delete("panel"); if (!validConvId) normalUrl.searchParams.delete("conversation"); history.replaceState( { panel: validPanel, conversation: validConvId }, "", normalUrl, ); } currentPanel = validPanel || "resume"; } /* ---- Load archive; only an explicit URL restores a conversation ---- */ const startupEpoch = principalEpoch; const ensureStartupPrincipal = () => { if (startupEpoch !== principalEpoch) { const error = new Error("Principal changed during startup"); error.name = "StalePrincipalError"; throw error; } }; archive?.setLoading(true); try { const data = await fetchConversationList(null); ensureStartupPrincipal(); archiveCursor = data.cursor || null; const conversations = data.conversations || []; conversations.forEach(c => _renderedIds.add(c.id)); archive?.setConversations(conversations, archiveCursor); /* A conversation opens only through an explicit URL or a user archive action. */ const initParams2 = new URLSearchParams(window.location.search); const urlConvId = initParams2.get("conversation"); const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null; const storedConvId = !_convParamWasExplicit ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY) : null; if (urlConvValid) { const inArchive = conversations.find(c => c.id === urlConvValid); if (inArchive) { try { const conversation = await requestJson( `/api/conversations/${encodeURIComponent(urlConvValid)}`, ); ensureStartupPrincipal(); currentConversationId = urlConvValid; sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid); chat?.replaceMessages(conversation.turns); archive?.setCurrentId(urlConvValid); } catch (error) { if (error.name === "StalePrincipalError" || startupEpoch !== principalEpoch) throw error; currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); } } else { /* Not on first page — fetch directly to verify ownership */ let ownedConversation = null; try { ownedConversation = await requestJson( `/api/conversations/${encodeURIComponent(urlConvValid)}`, ); ensureStartupPrincipal(); } catch (error) { if (error.name === "StalePrincipalError" || startupEpoch !== principalEpoch) throw error; ownedConversation = null; } if (ownedConversation) { currentConversationId = urlConvValid; sessionStorage.setItem(CONVERSATION_STORAGE_KEY, urlConvValid); chat?.replaceMessages(ownedConversation.turns); if (!_renderedIds.has(urlConvValid)) { _renderedIds.add(urlConvValid); archive?.addConversation({ id: urlConvValid, title: ownedConversation.title || "Conversation", turn_count: ownedConversation.turns?.length || 0, }); } archive?.setCurrentId(urlConvValid); } else { currentConversationId = null; sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); } } } else { currentConversationId = null; archive?.setCurrentId(null); if (conversations.length > 0) { chat?.setGreeting(greetingForSession(_sessionData, true), true); } if (storedConvId && UUID_REGEX.test(storedConvId)) { const storedConversationIsOwned = conversations.some( conversation => conversation.id === storedConvId, ); if (_sessionData?.kind === "user" && !storedConversationIsOwned) { sessionStorage.setItem(CONVERSATION_LEGACY_KEY, storedConvId); legacyClaimId = storedConvId; archive?.showLegacyClaim(); } sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); } } } catch (error) { if (error.name !== "StalePrincipalError" && startupEpoch === principalEpoch) { archive?.setError(`Archive error: ${error.message}`); } /* Fall back only for an explicit deep-linked conversation. */ const initParams3 = new URLSearchParams(window.location.search); const fallbackId = initParams3.get("conversation"); if (startupEpoch === principalEpoch && fallbackId && UUID_REGEX.test(fallbackId)) { try { const conversation = await requestJson( `/api/conversations/${encodeURIComponent(fallbackId)}`, ); ensureStartupPrincipal(); currentConversationId = fallbackId; chat?.replaceMessages(conversation.turns); } catch (fallbackError) { if (fallbackError.name === "StalePrincipalError" || startupEpoch !== principalEpoch) { // The new principal owns subsequent rendering. } else { sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); currentConversationId = null; } } } } /* Apply startup panel and normalize URL (replaceState — not a user navigation) */ if (startupEpoch === principalEpoch) { selectPanel(currentPanel, false); _replaceUrlState(currentPanel, currentConversationId); } shell?.addEventListener("mjj-jrpg-preview-change", event => { selectPanel(event.detail.selection); shell?.showMobileDestination(event.detail.selection); character?.setAttribute("state", "thinking"); window.clearTimeout(characterTimer); characterTimer = window.setTimeout(() => { character?.setAttribute("state", "default"); }, 650); }); shell?.addEventListener("mjj-composer-cancel", () => { activeController?.abort(); }); shell?.addEventListener("mjj-composer-submit", async event => { if (activeController) return; /* Block sends when password change is required */ if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) { return; } chat?.appendMessage("June", event.detail.message); composer?.setBusy(true); character?.setAttribute("state", "thinking"); window.clearTimeout(characterTimer); const assistantItem = chat?.startAssistantMessage(); 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 }); /* Disable archive/account mutations while stream is active (finding #7) */ archive?.setStreamActive(true); setAccountActionsDisabled(true); try { const createConversation = async () => { const resp = await mutatingFetch("/api/conversations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "Shiba Quest" }), }); if (!resp.ok) { let message = `Create failed (${resp.status})`; try { const body = await resp.json(); message = body.error?.message || message; } catch { /* keep */ } throw new Error(message); } const conversation = await resp.json(); currentConversationId = conversation.id; sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); _pushUrlState(currentPanel, currentConversationId); if (!_renderedIds.has(currentConversationId)) { _renderedIds.add(currentConversationId); archive?.addConversation({ id: currentConversationId, title: "Shiba Quest", turn_count: 0, last_message_preview: event.detail.message.slice(0, 32), }); } archive?.setCurrentId(currentConversationId); }; if (!currentConversationId) { await createConversation(); } const postTurn = () => mutatingFetch( `/api/conversations/${encodeURIComponent(currentConversationId)}/turns`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: event.detail.message }), signal: activeController.signal, }, ); let response = await postTurn(); /* Handle 404: conversation may have expired — recreate it */ if (response.status === 404) { sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); currentConversationId = null; await createConversation(); response = await postTurn(); } /* Handle 429: distinguish permanent quota from temporary capacity */ if (response.status === 429) { let errorBody = null; try { errorBody = await response.json(); } catch { /* keep */ } const code = errorBody?.error?.code || ""; const isQuotaExhausted = code === "guest_quota_turns_exhausted" || code === "guest_quota_tokens_exhausted" || code === "guest_turn_quota_exhausted" || code === "guest_token_quota_exhausted"; if (isQuotaExhausted) { const quotaState = errorBody?.error?.quota || null; updateQuotaDisplay(quotaEl, quotaState); composer?.setDisabled(true); chat?.setMessageStreaming(assistantItem, false); chat?.setMessageText( assistantItem, quotaState ? `Quota exhausted. ${formatQuotaText(quotaState)?.text || ""}` : "Quota exhausted. Try again later.", ); character?.setAttribute("state", "sad"); setFrameStatus("QUOTA", "offline"); /* Re-enable when quota resets */ const resetsAt = quotaState?.resetsAt; if (resetsAt) { const delay = Math.max(0, resetsAt * 1000 - Date.now()); window.setTimeout(() => { if (_sessionData?.kind !== "user" || !_sessionData?.mustChangePassword) { composer?.setDisabled(false); setFrameStatus("ONLINE", "online"); } }, Math.min(delay + 1000, 24 * 3600 * 1000)); } } else { /* Temporary capacity 429 — use Retry-After or default 5 s */ const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10); chat?.setMessageStreaming(assistantItem, false); chat?.setMessageText(assistantItem, "Server busy. Retrying shortly…"); character?.setAttribute("state", "sad"); setFrameStatus("BUSY", "offline"); window.setTimeout(() => { if (!activeController?.signal.aborted) { setFrameStatus("ONLINE", "online"); composer?.setDisabled(false); } }, retryAfter * 1000); } return; } if (!response.ok) { let message = `Turn failed (${response.status})`; try { const body = await response.json(); message = body.error?.message || message; } catch { /* keep */ } 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.append(data.delta || ""); } else if (eventName === "assistant.completed") { assistantText.complete(data.content); } else if (eventName === "turn.error") { throw new Error( data.error?.message || data.message || "Inference turn failed", ); } else if (eventName === "turn.done") { turnFinished = true; 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"); setFrameStatus("ONLINE", "online"); /* Refresh archive and quota after successful turn */ void refreshArchiveHead(); if (_sessionData?.kind === "guest") { _csrfToken = await _fetchSession(); updateQuotaDisplay(quotaEl, _sessionData?.quota || null); } } 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}`, ); character?.setAttribute("state", "sad"); if (!aborted) setFrameStatus("OFFLINE", "offline"); } finally { composer?.setBusy(false); composer?.querySelector("textarea")?.focus(); activeController = null; /* Re-enable archive/account mutations exactly once */ archive?.setStreamActive(false); setAccountActionsDisabled(false); /* Apply popstate that was queued during the stream (Fix 1) */ if (_pendingPopState) { const pending = _pendingPopState; _pendingPopState = null; if (pending.seq === popSeq) { await _applyPopState(pending.seq, pending.popPanel, pending.popConvId); } } } }); /* Enable composer unless forced password change */ if (!isForced) { composer?.setDisabled(false); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { void initializeJrpgPage(); }, { once: true, }); } else { void initializeJrpgPage(); }