Mercurial
comparison mrjunejune/src/jrpg/jrpg.js @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | ee04e4e69fed |
| children | 056790c4fb0d |
comparison
equal
deleted
inserted
replaced
| 263:ee04e4e69fed | 264:04fee26ecce0 |
|---|---|
| 13 }, | 13 }, |
| 14 }); | 14 }); |
| 15 | 15 |
| 16 const PREVIEWS = Object.freeze({ | 16 const PREVIEWS = Object.freeze({ |
| 17 resume: { | 17 resume: { |
| 18 copy: "Experience, projects, and the systems I have helped build.", | 18 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.", |
| 19 kicker: "CHARACTER RECORD", | |
| 20 title: "Resume", | 19 title: "Resume", |
| 21 url: "/resume", | 20 url: "/resume", |
| 22 works: [ | 21 works: [ |
| 23 { | 22 { |
| 24 detail: "Career dossier", | 23 detail: "Career dossier", |
| 25 label: "Full resume", | 24 label: "Full resume", |
| 26 modal: "resume", | 25 modal: "resume", |
| 27 url: "/resume", | 26 url: "/resume", |
| 28 }, | 27 }, |
| 29 { | 28 { |
| 30 detail: "Agentic execution", | 29 detail: "Led engineering", |
| 31 label: "Copilot Tasks", | 30 label: "Copilot Tasks", |
| 32 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", | 31 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", |
| 33 }, | 32 }, |
| 34 { | 33 { |
| 35 detail: "AI platform", | 34 detail: "Foundational platform", |
| 36 label: "Copilot SuperApp", | 35 label: "AIX Harness / Copilot", |
| 37 url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html", | 36 url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html", |
| 38 }, | 37 }, |
| 39 { | 38 { |
| 40 detail: "Build 2026", | 39 detail: "Build 2026 products", |
| 41 label: "Code", | 40 label: "Code & Autopilot", |
| 42 url: "https://news.microsoft.com/build-2026/", | 41 url: "https://news.microsoft.com/build-2026/", |
| 43 }, | 42 }, |
| 44 { | 43 { |
| 45 detail: "Personal agent", | 44 detail: "Full-stack ads systems", |
| 46 label: "Autopilot", | |
| 47 url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", | |
| 48 }, | |
| 49 { | |
| 50 detail: "Ads systems", | |
| 51 label: "Meta", | 45 label: "Meta", |
| 52 url: "https://www.meta.com/", | 46 url: "https://www.meta.com/", |
| 53 }, | 47 }, |
| 54 { | 48 { |
| 55 detail: "Workspace platform", | 49 detail: "Workspace platform", |
| 56 label: "Google", | 50 label: "Google", |
| 57 url: "https://www.google.com/", | 51 url: "https://www.google.com/", |
| 58 }, | 52 }, |
| 53 { | |
| 54 detail: "Technical lead", | |
| 55 label: "Warner Music Group", | |
| 56 url: "https://www.wmg.com/", | |
| 57 }, | |
| 58 { | |
| 59 detail: "Personal agent launch", | |
| 60 label: "Microsoft Scout", | |
| 61 url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", | |
| 62 }, | |
| 59 ], | 63 ], |
| 60 }, | 64 }, |
| 61 tools: { | 65 tools: { |
| 62 copy: "Small, focused utilities for writing, media, and experimentation.", | 66 copy: "Useful browser tools backed by first-party C, WASM, media, and document-processing systems.", |
| 63 kicker: "ITEM INVENTORY", | |
| 64 title: "Tools", | 67 title: "Tools", |
| 65 url: "/tools", | 68 url: "/tools", |
| 66 works: [ | 69 works: [ |
| 67 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, | 70 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, |
| 68 { detail: "Media", label: "Converter", url: "/tools/file_converter" }, | 71 { detail: "Media", label: "Converter", url: "/tools/file_converter" }, |
| 69 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, | 72 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, |
| 70 ], | 73 ], |
| 71 }, | 74 }, |
| 72 blog: { | 75 blog: { |
| 73 copy: "Notes from building systems, games, web tools, and curious prototypes.", | 76 copy: "Technical writing about networking, rendering, performance, developer tooling, and experiments.", |
| 74 kicker: "QUEST ARCHIVE", | |
| 75 title: "Blogs", | 77 title: "Blogs", |
| 76 url: "/blog", | 78 url: "/blog", |
| 77 works: [ | 79 works: [ |
| 78 { detail: "Archive", label: "All posts", url: "/blog" }, | 80 { detail: "Archive", label: "All posts", url: "/blog" }, |
| 79 ], | 81 ], |
| 80 }, | 82 }, |
| 81 }); | 83 }); |
| 82 | 84 |
| 83 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; | 85 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; |
| 86 const CONVERSATION_LEGACY_KEY = "mjj-jrpg-legacy-claim"; | |
| 87 const ARCHIVE_PAGE_SIZE = 20; | |
| 84 const TYPING_INTERVAL_MS = 18; | 88 const TYPING_INTERVAL_MS = 18; |
| 85 const SCRIPT_LOADS = new Map(); | 89 const SCRIPT_LOADS = new Map(); |
| 90 const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
| 91 const VALID_PANELS = Object.freeze(["resume", "tools", "blog", "conversations"]); | |
| 86 const TOOL_ICON_NAMES = Object.freeze({ | 92 const TOOL_ICON_NAMES = Object.freeze({ |
| 87 "/notes": "repository", | 93 "/notes": "repository", |
| 88 "/tools/file_converter": "retry", | 94 "/tools/file_converter": "retry", |
| 89 "/tools/hls_player": "play", | 95 "/tools/hls_player": "play", |
| 90 "/tools/latex_editor": "file", | 96 "/tools/latex_editor": "file", |
| 596 | 602 |
| 597 show(selection) { | 603 show(selection) { |
| 598 this.cleanupTool(); | 604 this.cleanupTool(); |
| 599 const preview = PREVIEWS[selection] || PREVIEWS.resume; | 605 const preview = PREVIEWS[selection] || PREVIEWS.resume; |
| 600 this.dataset.selection = selection; | 606 this.dataset.selection = selection; |
| 601 this.querySelector("[data-preview-kicker]").textContent = preview.kicker; | |
| 602 this.querySelector("[data-preview-title]").textContent = preview.title; | 607 this.querySelector("[data-preview-title]").textContent = preview.title; |
| 603 this.querySelector("[data-preview-copy]").textContent = preview.copy; | 608 this.querySelector("[data-preview-copy]").textContent = preview.copy; |
| 604 this.querySelector("[data-dialog-title]").textContent = preview.title; | 609 this.querySelector("[data-dialog-title]").textContent = preview.title; |
| 605 this.querySelector("[data-dialog-copy]").textContent = preview.copy; | 610 this.querySelector("[data-dialog-copy]").textContent = preview.copy; |
| 606 this.renderShowcase(preview.works); | 611 this.renderShowcase(preview.works); |
| 1399 "Read-only preview. Open the full page to use this tool."; | 1404 "Read-only preview. Open the full page to use this tool."; |
| 1400 return introduction; | 1405 return introduction; |
| 1401 } | 1406 } |
| 1402 } | 1407 } |
| 1403 | 1408 |
| 1404 class MjjJrpgShell extends HTMLElement {} | 1409 /* ===================================================================== |
| 1410 MjjConversationArchive — archive panel within the scene | |
| 1411 ===================================================================== */ | |
| 1412 | |
| 1413 class MjjConversationArchive extends HTMLElement { | |
| 1414 connectedCallback() { | |
| 1415 this._list = this.querySelector("[data-archive-list]"); | |
| 1416 this._status = this.querySelector("[data-archive-status]"); | |
| 1417 this._loadMoreOwner = this.querySelector("[data-archive-load-more-owner]"); | |
| 1418 this._loadMoreButton = this.querySelector("[data-archive-load-more]"); | |
| 1419 this._newButton = this.querySelector("[data-archive-new]"); | |
| 1420 this._closeButton = this.querySelector("[data-archive-close]"); | |
| 1421 this._deleteDialog = this.querySelector("[data-archive-delete-dialog]"); | |
| 1422 this._deleteNameEl = this.querySelector("[data-archive-delete-name]"); | |
| 1423 this._deleteCancelButton = this.querySelector("[data-archive-delete-cancel]"); | |
| 1424 this._deleteConfirmButton = this.querySelector("[data-archive-delete-confirm]"); | |
| 1425 this._renameDialog = this.querySelector("[data-archive-rename-dialog]"); | |
| 1426 this._renameInput = this.querySelector("[data-archive-rename-input]"); | |
| 1427 this._renameForm = this.querySelector("[data-archive-rename-form]"); | |
| 1428 this._renameCancelButton = this.querySelector("[data-archive-rename-cancel]"); | |
| 1429 this._pendingDeleteId = null; | |
| 1430 this._pendingDeleteTrigger = null; | |
| 1431 this._pendingRenameId = null; | |
| 1432 this._pendingRenameTrigger = null; | |
| 1433 | |
| 1434 this._closeButton?.addEventListener("click", () => { | |
| 1435 this.dispatchEvent(new CustomEvent("mjj-archive-toggle", { bubbles: true })); | |
| 1436 }); | |
| 1437 | |
| 1438 this._newButton?.addEventListener("click", () => { | |
| 1439 this.dispatchEvent(new CustomEvent("mjj-archive-new", { bubbles: true })); | |
| 1440 }); | |
| 1441 | |
| 1442 this._loadMoreButton?.addEventListener("click", () => { | |
| 1443 this.dispatchEvent(new CustomEvent("mjj-archive-load-more", { bubbles: true })); | |
| 1444 }); | |
| 1445 | |
| 1446 this._deleteCancelButton?.addEventListener("click", () => { | |
| 1447 this._deleteDialog?.close(); | |
| 1448 const t = this._pendingDeleteTrigger; | |
| 1449 this._pendingDeleteId = null; | |
| 1450 this._pendingDeleteTrigger = null; | |
| 1451 t?.focus(); | |
| 1452 }); | |
| 1453 | |
| 1454 this._deleteConfirmButton?.addEventListener("click", () => { | |
| 1455 const id = this._pendingDeleteId; | |
| 1456 const trigger = this._pendingDeleteTrigger; | |
| 1457 this._deleteDialog?.close(); | |
| 1458 this._pendingDeleteId = null; | |
| 1459 this._pendingDeleteTrigger = null; | |
| 1460 if (id) { | |
| 1461 this.dispatchEvent(new CustomEvent("mjj-archive-delete", { | |
| 1462 bubbles: true, | |
| 1463 detail: { id, trigger }, | |
| 1464 })); | |
| 1465 } | |
| 1466 }); | |
| 1467 | |
| 1468 this._deleteDialog?.addEventListener("close", () => { | |
| 1469 const t = this._pendingDeleteTrigger; | |
| 1470 this._pendingDeleteId = null; | |
| 1471 this._pendingDeleteTrigger = null; | |
| 1472 t?.focus(); | |
| 1473 }); | |
| 1474 | |
| 1475 this._renameCancelButton?.addEventListener("click", () => { | |
| 1476 this._renameDialog?.close(); | |
| 1477 const t = this._pendingRenameTrigger; | |
| 1478 this._pendingRenameId = null; | |
| 1479 this._pendingRenameTrigger = null; | |
| 1480 t?.focus(); | |
| 1481 }); | |
| 1482 | |
| 1483 this._renameForm?.addEventListener("submit", event => { | |
| 1484 event.preventDefault(); | |
| 1485 const id = this._pendingRenameId; | |
| 1486 const trigger = this._pendingRenameTrigger; | |
| 1487 const newTitle = this._renameInput?.value.trim(); | |
| 1488 if (!id || !newTitle) return; | |
| 1489 this._renameDialog?.close(); | |
| 1490 this._pendingRenameId = null; | |
| 1491 this._pendingRenameTrigger = null; | |
| 1492 this.dispatchEvent(new CustomEvent("mjj-archive-rename", { | |
| 1493 bubbles: true, | |
| 1494 detail: { id, newTitle, trigger }, | |
| 1495 })); | |
| 1496 }); | |
| 1497 | |
| 1498 this._renameDialog?.addEventListener("close", () => { | |
| 1499 const t = this._pendingRenameTrigger; | |
| 1500 this._pendingRenameId = null; | |
| 1501 this._pendingRenameTrigger = null; | |
| 1502 t?.focus(); | |
| 1503 }); | |
| 1504 | |
| 1505 this._onListClick = event => { | |
| 1506 const openButton = event.target.closest("[data-conv-open]"); | |
| 1507 if (openButton) { | |
| 1508 const id = openButton.closest("[data-conv-item]")?._convId; | |
| 1509 if (id) { | |
| 1510 this.dispatchEvent(new CustomEvent("mjj-archive-open", { | |
| 1511 bubbles: true, | |
| 1512 detail: { id }, | |
| 1513 })); | |
| 1514 } | |
| 1515 return; | |
| 1516 } | |
| 1517 const renameButton = event.target.closest("[data-conv-rename]"); | |
| 1518 if (renameButton) { | |
| 1519 const item = renameButton.closest("[data-conv-item]"); | |
| 1520 if (item?._convId) { | |
| 1521 this._pendingRenameId = item._convId; | |
| 1522 this._pendingRenameTrigger = renameButton; | |
| 1523 if (this._renameInput) this._renameInput.value = item._convTitle || ""; | |
| 1524 this._renameDialog?.showModal(); | |
| 1525 requestAnimationFrame(() => { this._renameInput?.select(); }); | |
| 1526 } | |
| 1527 return; | |
| 1528 } | |
| 1529 const deleteButton = event.target.closest("[data-conv-delete]"); | |
| 1530 if (deleteButton) { | |
| 1531 const item = deleteButton.closest("[data-conv-item]"); | |
| 1532 if (item?._convId) { | |
| 1533 this._pendingDeleteId = item._convId; | |
| 1534 this._pendingDeleteTrigger = deleteButton; | |
| 1535 if (this._deleteNameEl) this._deleteNameEl.textContent = item._convTitle || ""; | |
| 1536 this._deleteDialog?.showModal(); | |
| 1537 requestAnimationFrame(() => { this._deleteConfirmButton?.focus(); }); | |
| 1538 } | |
| 1539 return; | |
| 1540 } | |
| 1541 }; | |
| 1542 this._list?.addEventListener("click", this._onListClick); | |
| 1543 | |
| 1544 this._onListKeydown = event => { | |
| 1545 if (!["ArrowUp", "ArrowDown"].includes(event.key)) return; | |
| 1546 const items = [...(this._list?.querySelectorAll("[data-conv-open]") || [])]; | |
| 1547 const index = items.indexOf(document.activeElement); | |
| 1548 if (index < 0) return; | |
| 1549 event.preventDefault(); | |
| 1550 const next = event.key === "ArrowDown" | |
| 1551 ? items[Math.min(index + 1, items.length - 1)] | |
| 1552 : items[Math.max(index - 1, 0)]; | |
| 1553 next?.focus(); | |
| 1554 }; | |
| 1555 this._list?.addEventListener("keydown", this._onListKeydown); | |
| 1556 } | |
| 1557 | |
| 1558 disconnectedCallback() { | |
| 1559 this._list?.removeEventListener("click", this._onListClick); | |
| 1560 this._list?.removeEventListener("keydown", this._onListKeydown); | |
| 1561 } | |
| 1562 | |
| 1563 setConversations(conversations, cursor, append = false) { | |
| 1564 if (!this._list) return; | |
| 1565 if (!append) this._list.replaceChildren(); | |
| 1566 for (const conv of conversations) this._list.append(this._createItem(conv)); | |
| 1567 if (this._loadMoreOwner) this._loadMoreOwner.hidden = !cursor; | |
| 1568 if (!conversations.length && !append) { | |
| 1569 this._showStatus("No conversations yet. Start a new one!"); | |
| 1570 } else { | |
| 1571 this._hideStatus(); | |
| 1572 } | |
| 1573 } | |
| 1574 | |
| 1575 setCurrentId(id) { | |
| 1576 for (const button of (this._list?.querySelectorAll("[data-conv-open]") || [])) { | |
| 1577 const isCurrent = id && button.closest("[data-conv-item]")?._convId === id; | |
| 1578 button.setAttribute("aria-current", String(Boolean(isCurrent))); | |
| 1579 } | |
| 1580 } | |
| 1581 | |
| 1582 addConversation(conv) { | |
| 1583 if (!this._list) return; | |
| 1584 this._list.prepend(this._createItem(conv)); | |
| 1585 this._hideStatus(); | |
| 1586 } | |
| 1587 | |
| 1588 updateConversation(conv) { | |
| 1589 if (!this._list) return; | |
| 1590 for (const item of this._list.querySelectorAll("[data-conv-item]")) { | |
| 1591 if (item._convId === conv.id) { | |
| 1592 const titleEl = item.querySelector("[data-conv-title]"); | |
| 1593 const metaEl = item.querySelector("[data-conv-meta]"); | |
| 1594 if (titleEl) titleEl.textContent = conv.title; | |
| 1595 if (metaEl) metaEl.textContent = this._formatMeta(conv); | |
| 1596 item._convTitle = conv.title; | |
| 1597 return; | |
| 1598 } | |
| 1599 } | |
| 1600 this.addConversation(conv); | |
| 1601 } | |
| 1602 | |
| 1603 removeConversation(id) { | |
| 1604 for (const item of (this._list?.querySelectorAll("[data-conv-item]") || [])) { | |
| 1605 if (item._convId === id) { | |
| 1606 item.remove(); | |
| 1607 break; | |
| 1608 } | |
| 1609 } | |
| 1610 if (!this._list?.querySelector("[data-conv-item]")) { | |
| 1611 this._showStatus("No conversations yet. Start a new one!"); | |
| 1612 } | |
| 1613 } | |
| 1614 | |
| 1615 setLoading(loading) { | |
| 1616 if (loading) { | |
| 1617 this._showStatus("Loading conversations..."); | |
| 1618 } else if (!this._list?.querySelector("[data-conv-item]")) { | |
| 1619 this._showStatus("No conversations yet. Start a new one!"); | |
| 1620 } else { | |
| 1621 this._hideStatus(); | |
| 1622 } | |
| 1623 } | |
| 1624 | |
| 1625 setError(message) { | |
| 1626 this._showStatus(message); | |
| 1627 if (this._status) this._status.dataset.state = "error"; | |
| 1628 } | |
| 1629 | |
| 1630 setLoadMoreDisabled(disabled) { | |
| 1631 if (this._loadMoreButton) this._loadMoreButton.disabled = disabled; | |
| 1632 } | |
| 1633 | |
| 1634 /* Disable/enable destructive archive controls while a stream is active. */ | |
| 1635 setStreamActive(active) { | |
| 1636 const controls = this._list?.querySelectorAll( | |
| 1637 "[data-conv-delete], [data-conv-rename]", | |
| 1638 ) || []; | |
| 1639 for (const el of controls) el.disabled = active; | |
| 1640 if (this._newButton) this._newButton.disabled = active; | |
| 1641 if (this._deleteConfirmButton) this._deleteConfirmButton.disabled = active; | |
| 1642 this.dataset.streamActive = active ? "true" : ""; | |
| 1643 if (!active) delete this.dataset.streamActive; | |
| 1644 } | |
| 1645 | |
| 1646 /* Return the open-button of the adjacent surviving item, or New button. */ | |
| 1647 adjacentItemOrNewButton(id) { | |
| 1648 const items = [...(this._list?.querySelectorAll("[data-conv-item]") || [])]; | |
| 1649 const idx = items.findIndex(el => el._convId === id); | |
| 1650 if (idx < 0) return this._newButton; | |
| 1651 const sibling = items[idx + 1] || items[idx - 1]; | |
| 1652 return sibling?.querySelector("[data-conv-open]") || this._newButton; | |
| 1653 } | |
| 1654 | |
| 1655 showLegacyClaim(statusMessage = null, showButton = true) { | |
| 1656 if (!this._status) return; | |
| 1657 this._status.hidden = false; | |
| 1658 delete this._status.dataset.state; | |
| 1659 this._status.textContent = ""; | |
| 1660 const msg = document.createElement("span"); | |
| 1661 msg.textContent = statusMessage ?? "You have a prior conversation. "; | |
| 1662 this._status.append(msg); | |
| 1663 if (showButton) { | |
| 1664 const btn = document.createElement("button"); | |
| 1665 btn.type = "button"; | |
| 1666 btn.textContent = "Claim it"; | |
| 1667 btn.dataset.archiveClaim = ""; | |
| 1668 btn.addEventListener("click", () => { | |
| 1669 this.dispatchEvent(new CustomEvent("mjj-archive-claim", { bubbles: true })); | |
| 1670 }); | |
| 1671 this._status.append(btn); | |
| 1672 } | |
| 1673 } | |
| 1674 | |
| 1675 _showStatus(text) { | |
| 1676 if (this._status) { | |
| 1677 this._status.textContent = text; | |
| 1678 this._status.hidden = false; | |
| 1679 delete this._status.dataset.state; | |
| 1680 } | |
| 1681 } | |
| 1682 | |
| 1683 _hideStatus() { | |
| 1684 if (this._status) this._status.hidden = true; | |
| 1685 } | |
| 1686 | |
| 1687 _createItem(conv) { | |
| 1688 const item = document.createElement("li"); | |
| 1689 item.dataset.convItem = ""; | |
| 1690 item._convId = conv.id; | |
| 1691 item._convTitle = conv.title; | |
| 1692 | |
| 1693 const openButton = document.createElement("button"); | |
| 1694 openButton.type = "button"; | |
| 1695 openButton.dataset.convOpen = ""; | |
| 1696 openButton.setAttribute("aria-current", "false"); | |
| 1697 | |
| 1698 const titleEl = document.createElement("span"); | |
| 1699 titleEl.dataset.convTitle = ""; | |
| 1700 titleEl.textContent = conv.title; | |
| 1701 | |
| 1702 const metaEl = document.createElement("small"); | |
| 1703 metaEl.dataset.convMeta = ""; | |
| 1704 metaEl.textContent = this._formatMeta(conv); | |
| 1705 | |
| 1706 openButton.append(titleEl, metaEl); | |
| 1707 | |
| 1708 const actions = document.createElement("div"); | |
| 1709 actions.className = "jrpg-archive-item-actions"; | |
| 1710 | |
| 1711 const renameButton = document.createElement("button"); | |
| 1712 renameButton.type = "button"; | |
| 1713 renameButton.dataset.convRename = ""; | |
| 1714 renameButton.setAttribute("aria-label", `Rename "${conv.title}"`); | |
| 1715 renameButton.textContent = "Rename"; | |
| 1716 | |
| 1717 const deleteButton = document.createElement("button"); | |
| 1718 deleteButton.type = "button"; | |
| 1719 deleteButton.dataset.convDelete = ""; | |
| 1720 deleteButton.setAttribute("aria-label", `Delete "${conv.title}"`); | |
| 1721 deleteButton.textContent = "Delete"; | |
| 1722 | |
| 1723 actions.append(renameButton, deleteButton); | |
| 1724 item.append(openButton, actions); | |
| 1725 return item; | |
| 1726 } | |
| 1727 | |
| 1728 _formatMeta(conv) { | |
| 1729 const count = conv.turn_count ?? 0; | |
| 1730 const turns = count === 1 ? "1 turn" : `${count} turns`; | |
| 1731 if (conv.last_message_preview) { | |
| 1732 return `${turns} · ${conv.last_message_preview.slice(0, 28)}`; | |
| 1733 } | |
| 1734 return turns; | |
| 1735 } | |
| 1736 } | |
| 1737 | |
| 1738 class MjjJrpgShell extends HTMLElement { | |
| 1739 connectedCallback() { | |
| 1740 this._mobileMenuQuery = window.matchMedia( | |
| 1741 "(max-width: 52rem) and (orientation: portrait)", | |
| 1742 ); | |
| 1743 this._syncMobileMenu = expanded => { | |
| 1744 const button = this.querySelector("[data-mobile-menu-toggle]"); | |
| 1745 const menu = this.querySelector("mjj-jrpg-menu"); | |
| 1746 if (!button || !menu) return; | |
| 1747 if (!this._mobileMenuQuery.matches) { | |
| 1748 button.setAttribute("aria-expanded", "true"); | |
| 1749 menu.removeAttribute("data-mobile-menu-collapsed"); | |
| 1750 return; | |
| 1751 } | |
| 1752 button.setAttribute("aria-expanded", String(expanded)); | |
| 1753 menu.toggleAttribute("data-mobile-menu-collapsed", !expanded); | |
| 1754 }; | |
| 1755 this._onMenuToggle = event => { | |
| 1756 const button = event.target.closest("[data-mobile-menu-toggle]"); | |
| 1757 if (!button || !this.contains(button)) return; | |
| 1758 const expanded = button.getAttribute("aria-expanded") === "true"; | |
| 1759 this._syncMobileMenu(!expanded); | |
| 1760 if (!expanded) { | |
| 1761 const firstItem = this.querySelector( | |
| 1762 "mjj-jrpg-menu button[data-preview]", | |
| 1763 ); | |
| 1764 if (firstItem) firstItem.focus(); | |
| 1765 } | |
| 1766 }; | |
| 1767 this._onMobileMenuMediaChange = () => this._syncMobileMenu(true); | |
| 1768 this.addEventListener("click", this._onMenuToggle); | |
| 1769 this._mobileMenuQuery.addEventListener( | |
| 1770 "change", | |
| 1771 this._onMobileMenuMediaChange, | |
| 1772 ); | |
| 1773 this._syncMobileMenu(true); | |
| 1774 } | |
| 1775 | |
| 1776 disconnectedCallback() { | |
| 1777 this.removeEventListener("click", this._onMenuToggle); | |
| 1778 this._mobileMenuQuery?.removeEventListener( | |
| 1779 "change", | |
| 1780 this._onMobileMenuMediaChange, | |
| 1781 ); | |
| 1782 } | |
| 1783 } | |
| 1405 | 1784 |
| 1406 for (const [name, constructor] of Object.entries({ | 1785 for (const [name, constructor] of Object.entries({ |
| 1786 "mjj-conversation-archive": MjjConversationArchive, | |
| 1407 "mjj-jrpg-character": MjjJrpgCharacter, | 1787 "mjj-jrpg-character": MjjJrpgCharacter, |
| 1408 "mjj-jrpg-chat": MjjJrpgChat, | 1788 "mjj-jrpg-chat": MjjJrpgChat, |
| 1409 "mjj-jrpg-composer": MjjJrpgComposer, | 1789 "mjj-jrpg-composer": MjjJrpgComposer, |
| 1410 "mjj-jrpg-menu": MjjJrpgMenu, | 1790 "mjj-jrpg-menu": MjjJrpgMenu, |
| 1411 "mjj-jrpg-preview": MjjJrpgPreview, | 1791 "mjj-jrpg-preview": MjjJrpgPreview, |
| 1415 } | 1795 } |
| 1416 | 1796 |
| 1417 async function requestJson(url, options = {}) { | 1797 async function requestJson(url, options = {}) { |
| 1418 const response = await fetch(url, { | 1798 const response = await fetch(url, { |
| 1419 ...options, | 1799 ...options, |
| 1800 credentials: "same-origin", | |
| 1420 headers: { | 1801 headers: { |
| 1421 "Content-Type": "application/json", | 1802 "Content-Type": "application/json", |
| 1422 ...options.headers, | 1803 ...options.headers, |
| 1423 }, | 1804 }, |
| 1424 }); | 1805 }); |
| 1433 throw new Error(message); | 1814 throw new Error(message); |
| 1434 } | 1815 } |
| 1435 return response.status === 204 ? null : response.json(); | 1816 return response.status === 204 ? null : response.json(); |
| 1436 } | 1817 } |
| 1437 | 1818 |
| 1819 /* Session state for CSRF: bootstrapped once, refreshed at most once on 401/403 */ | |
| 1820 let _csrfToken = null; | |
| 1821 let _sessionData = null; | |
| 1822 let _sessionRefreshInFlight = null; /* single-flight guard */ | |
| 1823 | |
| 1824 function _principalFingerprint(data) { | |
| 1825 if (!data) return null; | |
| 1826 if (data.kind === "user") return `user:${data.username || ""}:${data.role || ""}`; | |
| 1827 if (data.kind === "guest") return `guest:${data.csrfToken || ""}`; | |
| 1828 return null; | |
| 1829 } | |
| 1830 | |
| 1831 async function _fetchSession() { | |
| 1832 try { | |
| 1833 const response = await fetch("/api/auth/session", { | |
| 1834 credentials: "same-origin", | |
| 1835 }); | |
| 1836 if (!response.ok) return null; | |
| 1837 const data = await response.json().catch(() => null); | |
| 1838 if (!data) return null; | |
| 1839 _sessionData = data; | |
| 1840 return data.csrfToken || null; | |
| 1841 } catch { | |
| 1842 return null; | |
| 1843 } | |
| 1844 } | |
| 1845 | |
| 1846 /* Single-flight session refresh: dedupe concurrent callers. */ | |
| 1847 async function _refreshSessionOnce() { | |
| 1848 if (_sessionRefreshInFlight) return _sessionRefreshInFlight; | |
| 1849 _sessionRefreshInFlight = (async () => { | |
| 1850 const prev = _principalFingerprint(_sessionData); | |
| 1851 const token = await _fetchSession(); | |
| 1852 const next = _principalFingerprint(_sessionData); | |
| 1853 return { token, prev, next }; | |
| 1854 })(); | |
| 1855 try { | |
| 1856 return await _sessionRefreshInFlight; | |
| 1857 } finally { | |
| 1858 _sessionRefreshInFlight = null; | |
| 1859 } | |
| 1860 } | |
| 1861 | |
| 1862 /* | |
| 1863 * Perform a state-changing (mutating) fetch with CSRF token and same-origin | |
| 1864 * credentials. On 401 or CSRF-specific 403 (csrf_invalid), refreshes the | |
| 1865 * session once and retries — but only if the refreshed principal matches the | |
| 1866 * original. If the user becomes a guest or a different account, rejects and | |
| 1867 * triggers the session-changed callback. | |
| 1868 */ | |
| 1869 let _sessionRefreshCallback = null; | |
| 1870 | |
| 1871 async function mutatingFetch(url, options = {}) { | |
| 1872 const buildHeaders = () => ({ | |
| 1873 ...(options.headers || {}), | |
| 1874 Origin: window.location.origin, | |
| 1875 ...(_csrfToken ? { "X-CSRF-Token": _csrfToken } : {}), | |
| 1876 }); | |
| 1877 | |
| 1878 const response = await fetch(url, { | |
| 1879 ...options, | |
| 1880 credentials: "same-origin", | |
| 1881 headers: buildHeaders(), | |
| 1882 }); | |
| 1883 | |
| 1884 const shouldRefresh = response.status === 401 || | |
| 1885 (response.status === 403 && await (async () => { | |
| 1886 try { | |
| 1887 const clone = response.clone(); | |
| 1888 const body = await clone.json(); | |
| 1889 return body?.error?.code === "csrf_invalid"; | |
| 1890 } catch { return false; } | |
| 1891 })()); | |
| 1892 | |
| 1893 if (shouldRefresh) { | |
| 1894 const prevFingerprint = _principalFingerprint(_sessionData); | |
| 1895 const { token, next } = await _refreshSessionOnce(); | |
| 1896 _csrfToken = token; | |
| 1897 if (next !== prevFingerprint) { | |
| 1898 if (_sessionRefreshCallback) _sessionRefreshCallback(); | |
| 1899 const err = Object.assign(new Error("Session changed"), { status: 401 }); | |
| 1900 throw err; | |
| 1901 } | |
| 1902 const retried = await fetch(url, { | |
| 1903 ...options, | |
| 1904 credentials: "same-origin", | |
| 1905 headers: buildHeaders(), | |
| 1906 }); | |
| 1907 if (retried.status === 401 && _sessionRefreshCallback) { | |
| 1908 _sessionRefreshCallback(); | |
| 1909 } | |
| 1910 return retried; | |
| 1911 } | |
| 1912 return response; | |
| 1913 } | |
| 1914 | |
| 1438 async function consumeSse(response, onEvent) { | 1915 async function consumeSse(response, onEvent) { |
| 1439 if (!response.body) throw new Error("Streaming response body is unavailable"); | 1916 if (!response.body) throw new Error("Streaming response body is unavailable"); |
| 1440 const reader = response.body.getReader(); | 1917 const reader = response.body.getReader(); |
| 1441 const decoder = new TextDecoder(); | 1918 const decoder = new TextDecoder(); |
| 1442 let buffer = ""; | 1919 let buffer = ""; |
| 1465 if (done) break; | 1942 if (done) break; |
| 1466 } | 1943 } |
| 1467 if (buffer.trim()) dispatch(buffer); | 1944 if (buffer.trim()) dispatch(buffer); |
| 1468 } | 1945 } |
| 1469 | 1946 |
| 1947 /* ===================================================================== | |
| 1948 Account slot rendering | |
| 1949 ===================================================================== */ | |
| 1950 | |
| 1951 function renderAccountSlot(slotEl, sessionData) { | |
| 1952 if (!slotEl) return; | |
| 1953 slotEl.textContent = ""; | |
| 1954 if (!sessionData || sessionData.kind === "guest") { | |
| 1955 const owner = document.createElement("zen-button"); | |
| 1956 const btn = document.createElement("button"); | |
| 1957 owner.setAttribute("appearance", "plain"); | |
| 1958 owner.setAttribute("size", "xs"); | |
| 1959 btn.type = "button"; | |
| 1960 btn.dataset.loginOpen = ""; | |
| 1961 btn.textContent = "LOGIN"; | |
| 1962 owner.append(btn); | |
| 1963 slotEl.setAttribute("aria-label", "Not signed in"); | |
| 1964 slotEl.append(owner); | |
| 1965 return; | |
| 1966 } | |
| 1967 const username = (sessionData.username || "").slice(0, 8).toUpperCase(); | |
| 1968 if (sessionData.mustChangePassword) { | |
| 1969 const link = document.createElement("a"); | |
| 1970 link.href = "/account/password"; | |
| 1971 link.textContent = username ? `${username}\u26A0` : "PASSWD"; | |
| 1972 link.title = "Password change required"; | |
| 1973 slotEl.setAttribute("aria-label", `${sessionData.username}: password change required`); | |
| 1974 slotEl.append(link); | |
| 1975 return; | |
| 1976 } | |
| 1977 const nameSpan = document.createElement("span"); | |
| 1978 nameSpan.dataset.accountUser = ""; | |
| 1979 nameSpan.textContent = username; | |
| 1980 slotEl.append(nameSpan); | |
| 1981 if (sessionData.role === "admin") { | |
| 1982 const adminLink = document.createElement("a"); | |
| 1983 adminLink.href = "/admin/users"; | |
| 1984 adminLink.textContent = "\u00A0ADM"; | |
| 1985 slotEl.append(adminLink); | |
| 1986 } | |
| 1987 const logoutBtn = document.createElement("button"); | |
| 1988 logoutBtn.type = "button"; | |
| 1989 logoutBtn.textContent = "\u00A0LGOUT"; | |
| 1990 logoutBtn.addEventListener("click", () => { | |
| 1991 logoutBtn.disabled = true; | |
| 1992 mutatingFetch("/api/auth/logout", { method: "POST" }).then(response => { | |
| 1993 if (!response.ok) throw new Error(`Logout failed (${response.status})`); | |
| 1994 window.dispatchEvent(new CustomEvent("mjj-logout")); | |
| 1995 }).catch(() => { | |
| 1996 logoutBtn.disabled = false; | |
| 1997 }); | |
| 1998 }); | |
| 1999 slotEl.append(logoutBtn); | |
| 2000 slotEl.setAttribute("aria-label", `Signed in as ${sessionData.username}`); | |
| 2001 } | |
| 2002 | |
| 2003 /* ===================================================================== | |
| 2004 Quota display helpers | |
| 2005 ===================================================================== */ | |
| 2006 | |
| 2007 function formatQuotaText(quota) { | |
| 2008 if (!quota) return null; | |
| 2009 const turns = quota.turnsRemaining ?? 0; | |
| 2010 const tokens = quota.outputTokensRemaining ?? 0; | |
| 2011 if (turns <= 0) { | |
| 2012 const resetAt = quota.resetsAt ? new Date(quota.resetsAt * 1000) : null; | |
| 2013 const resetStr = resetAt | |
| 2014 ? ` Resets ${resetAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` | |
| 2015 : ""; | |
| 2016 return { text: `Quota exhausted.${resetStr}`, state: "exhausted" }; | |
| 2017 } | |
| 2018 if (turns <= 2) { | |
| 2019 return { | |
| 2020 text: `${turns} turn${turns === 1 ? "" : "s"} remaining (${tokens.toLocaleString()} tokens)`, | |
| 2021 state: "warning", | |
| 2022 }; | |
| 2023 } | |
| 2024 return { | |
| 2025 text: `${turns} turns remaining (${tokens.toLocaleString()} tokens)`, | |
| 2026 state: "ok", | |
| 2027 }; | |
| 2028 } | |
| 2029 | |
| 2030 function updateQuotaDisplay(quotaEl, quota) { | |
| 2031 if (!quotaEl) return; | |
| 2032 if (!quota) { | |
| 2033 quotaEl.hidden = true; | |
| 2034 return; | |
| 2035 } | |
| 2036 const result = formatQuotaText(quota); | |
| 2037 /* Only show the bar for low or exhausted quota; hide when turns are plentiful */ | |
| 2038 if (!result || result.state === "ok") { | |
| 2039 quotaEl.hidden = true; | |
| 2040 return; | |
| 2041 } | |
| 2042 quotaEl.textContent = result.text; | |
| 2043 if (result.state === "exhausted") { | |
| 2044 quotaEl.dataset.state = "exhausted"; | |
| 2045 } else if (result.state === "warning") { | |
| 2046 quotaEl.dataset.state = "warning"; | |
| 2047 } else { | |
| 2048 delete quotaEl.dataset.state; | |
| 2049 } | |
| 2050 quotaEl.hidden = false; | |
| 2051 } | |
| 2052 | |
| 2053 /* ===================================================================== | |
| 2054 Conversation archive helpers | |
| 2055 ===================================================================== */ | |
| 2056 | |
| 2057 async function fetchConversationList(cursor) { | |
| 2058 const url = cursor | |
| 2059 ? `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}&cursor=${encodeURIComponent(cursor)}` | |
| 2060 : `/api/conversations?limit=${ARCHIVE_PAGE_SIZE}`; | |
| 2061 const response = await fetch(url, { credentials: "same-origin" }); | |
| 2062 if (!response.ok) { | |
| 2063 let message = `Failed to load conversations (${response.status})`; | |
| 2064 try { | |
| 2065 const body = await response.json(); | |
| 2066 message = body.error?.message || message; | |
| 2067 } catch { /* keep status message */ } | |
| 2068 throw new Error(message); | |
| 2069 } | |
| 2070 return response.json(); | |
| 2071 } | |
| 2072 | |
| 1470 async function initializeJrpgPage() { | 2073 async function initializeJrpgPage() { |
| 1471 const colorProbe = document.createElement("span"); | 2074 const colorProbe = document.createElement("span"); |
| 1472 colorProbe.style.background = "var(--zenbu-sys-color-surface-page)"; | 2075 colorProbe.style.background = "var(--zenbu-sys-color-surface-page)"; |
| 1473 document.body.append(colorProbe); | 2076 document.body.append(colorProbe); |
| 1474 const themeColor = getComputedStyle(colorProbe).backgroundColor; | 2077 const themeColor = getComputedStyle(colorProbe).backgroundColor; |
| 1475 colorProbe.remove(); | 2078 colorProbe.remove(); |
| 1476 for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { | 2079 for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { |
| 1477 meta.setAttribute("content", themeColor); | 2080 meta.setAttribute("content", themeColor); |
| 1478 } | 2081 } |
| 1479 | 2082 |
| 2083 /* Bootstrap session to obtain CSRF token and session data */ | |
| 2084 _csrfToken = await _fetchSession(); | |
| 2085 | |
| 1480 const shell = document.querySelector("mjj-jrpg-shell"); | 2086 const shell = document.querySelector("mjj-jrpg-shell"); |
| 1481 const character = shell?.querySelector("mjj-jrpg-character"); | 2087 const character = shell?.querySelector("mjj-jrpg-character"); |
| 1482 const chat = shell?.querySelector("mjj-jrpg-chat"); | 2088 const chat = shell?.querySelector("mjj-jrpg-chat"); |
| 1483 const composer = shell?.querySelector("mjj-jrpg-composer"); | 2089 const composer = shell?.querySelector("mjj-jrpg-composer"); |
| 1484 const preview = shell?.querySelector("mjj-jrpg-preview"); | 2090 const preview = shell?.querySelector("mjj-jrpg-preview"); |
| 2091 const archive = shell?.querySelector("mjj-conversation-archive"); | |
| 2092 const accountSlot = shell?.querySelector("[data-frame-account]"); | |
| 2093 const quotaEl = shell?.querySelector("[data-quota]"); | |
| 1485 const frameNetwork = shell?.querySelector("[data-frame-network]"); | 2094 const frameNetwork = shell?.querySelector("[data-frame-network]"); |
| 1486 const frameUptime = shell?.querySelector("[data-frame-uptime]"); | 2095 const frameUptime = shell?.querySelector("[data-frame-uptime]"); |
| 1487 const minimizeButton = shell?.querySelector("[data-frame-minimize]"); | 2096 const minimizeButton = shell?.querySelector("[data-frame-minimize]"); |
| 1488 const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); | 2097 const fullscreenButton = shell?.querySelector("[data-frame-fullscreen]"); |
| 2098 | |
| 1489 let characterTimer = 0; | 2099 let characterTimer = 0; |
| 1490 let conversationId = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); | 2100 let currentConversationId = null; |
| 2101 let currentPanel = "resume"; | |
| 2102 let archiveCursor = null; | |
| 1491 let activeController = null; | 2103 let activeController = null; |
| 2104 let archiveLoadInFlight = false; /* serialize pagination requests */ | |
| 2105 let openSeq = 0; /* sequence guard for conversation open */ | |
| 2106 let popSeq = 0; /* sequence guard for popstate */ | |
| 2107 let _pendingPopState = null; /* queued popstate received during active stream */ | |
| 2108 let _claimInFlight = false; /* guard against duplicate concurrent claims */ | |
| 2109 let legacyClaimId = sessionStorage.getItem(CONVERSATION_LEGACY_KEY); | |
| 2110 let principalEpoch = 0; /* rejects responses from a prior owner */ | |
| 2111 /* Dedupe conversation IDs: set of IDs currently rendered in archive */ | |
| 2112 const _renderedIds = new Set(); | |
| 2113 const invalidatePrincipalRequests = () => { | |
| 2114 principalEpoch += 1; | |
| 2115 openSeq += 1; | |
| 2116 popSeq += 1; | |
| 2117 archiveCursor = null; | |
| 2118 }; | |
| 2119 | |
| 2120 /* Disable composer until fully initialized */ | |
| 1492 composer?.setDisabled(true); | 2121 composer?.setDisabled(true); |
| 2122 | |
| 1493 const setFrameStatus = (label, state, title = "") => { | 2123 const setFrameStatus = (label, state, title = "") => { |
| 1494 if (frameNetwork) { | 2124 if (frameNetwork) { |
| 1495 frameNetwork.textContent = label; | 2125 frameNetwork.textContent = label; |
| 1496 frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`); | 2126 frameNetwork.setAttribute("aria-label", `Status ${label.toLowerCase()}`); |
| 1497 frameNetwork.dataset.state = state; | 2127 frameNetwork.dataset.state = state; |
| 1498 frameNetwork.title = title; | 2128 frameNetwork.title = title; |
| 1499 } | 2129 } |
| 1500 }; | 2130 }; |
| 2131 | |
| 2132 /* Bootstrap failure: keep controls disabled and expose retry */ | |
| 2133 if (!_csrfToken) { | |
| 2134 setFrameStatus("OFFLINE", "offline", "Bootstrap failed"); | |
| 2135 archive?.setError("Connection failed. Reload to retry."); | |
| 2136 return; | |
| 2137 } | |
| 2138 | |
| 1501 setFrameStatus("ONLINE", "online"); | 2139 setFrameStatus("ONLINE", "online"); |
| 2140 | |
| 1502 const startedAt = Date.now(); | 2141 const startedAt = Date.now(); |
| 1503 const updateUptime = () => { | 2142 const updateUptime = () => { |
| 1504 const elapsed = Math.floor((Date.now() - startedAt) / 1000); | 2143 const elapsed = Math.floor((Date.now() - startedAt) / 1000); |
| 1505 const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0"); | 2144 const hours = String(Math.floor(elapsed / 3600)).padStart(2, "0"); |
| 1506 const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0"); | 2145 const minutes = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0"); |
| 1534 "aria-pressed", | 2173 "aria-pressed", |
| 1535 String(Boolean(document.fullscreenElement)), | 2174 String(Boolean(document.fullscreenElement)), |
| 1536 ); | 2175 ); |
| 1537 }); | 2176 }); |
| 1538 | 2177 |
| 1539 if (conversationId) { | 2178 /* Render account slot */ |
| 2179 renderAccountSlot(accountSlot, _sessionData); | |
| 2180 | |
| 2181 /* ---- Login modal ---- */ | |
| 2182 const loginDialogOwner = shell?.querySelector("[data-login-dialog-owner]"); | |
| 2183 const loginDialog = loginDialogOwner?.querySelector("[data-login-dialog]"); | |
| 2184 const loginForm = loginDialog?.querySelector("[data-login-form]"); | |
| 2185 const loginSubmitBtn = loginDialog?.querySelector("[data-login-submit]"); | |
| 2186 const loginCancelBtn = loginDialog?.querySelector("[data-login-cancel]"); | |
| 2187 const loginErrorEl = loginDialog?.querySelector("[data-login-error]"); | |
| 2188 const loginUsernameInput = loginDialog?.querySelector("#jrpg-login-username"); | |
| 2189 const loginPasswordInput = loginDialog?.querySelector("#jrpg-login-password"); | |
| 2190 let _loginTrigger = null; | |
| 2191 const setAccountActionsDisabled = disabled => { | |
| 2192 for (const control of accountSlot?.querySelectorAll("button") || []) { | |
| 2193 control.disabled = disabled; | |
| 2194 } | |
| 2195 }; | |
| 2196 | |
| 2197 const _clearLoginError = () => { | |
| 2198 if (loginErrorEl) { | |
| 2199 loginErrorEl.textContent = ""; | |
| 2200 loginErrorEl.hidden = true; | |
| 2201 } | |
| 2202 }; | |
| 2203 | |
| 2204 const _showLoginError = (message) => { | |
| 2205 if (!loginErrorEl) return; | |
| 2206 loginErrorEl.textContent = message; | |
| 2207 loginErrorEl.hidden = false; | |
| 2208 }; | |
| 2209 | |
| 2210 const openLoginModal = (triggerEl) => { | |
| 2211 if (!loginDialogOwner || !loginDialog) return; | |
| 2212 _loginTrigger = triggerEl || null; | |
| 2213 _clearLoginError(); | |
| 2214 loginForm?.reset(); | |
| 2215 loginDialogOwner.open(); | |
| 2216 requestAnimationFrame(() => { loginUsernameInput?.focus(); }); | |
| 2217 }; | |
| 2218 | |
| 2219 /* Delegate clicks on data-login-open in the account slot */ | |
| 2220 accountSlot?.addEventListener("click", event => { | |
| 2221 const btn = event.target.closest("[data-login-open]"); | |
| 2222 if (!btn || activeController) return; | |
| 2223 openLoginModal(btn); | |
| 2224 }); | |
| 2225 | |
| 2226 /* Cancel button */ | |
| 2227 loginCancelBtn?.addEventListener("click", () => { loginDialog?.close(); }); | |
| 2228 | |
| 2229 /* Cleanup on any close (Escape, X button, cancel) */ | |
| 2230 loginDialog?.addEventListener("close", () => { | |
| 2231 if (loginPasswordInput) loginPasswordInput.value = ""; | |
| 2232 _clearLoginError(); | |
| 2233 const trigger = _loginTrigger; | |
| 2234 _loginTrigger = null; | |
| 2235 requestAnimationFrame(() => { trigger?.focus(); }); | |
| 2236 }); | |
| 2237 | |
| 2238 /* Login form submission */ | |
| 2239 loginForm?.addEventListener("submit", async event => { | |
| 2240 event.preventDefault(); | |
| 2241 if (loginSubmitBtn?.disabled) return; | |
| 2242 const username = loginUsernameInput?.value.trim() || ""; | |
| 2243 const password = loginPasswordInput?.value || ""; | |
| 2244 if (!username || !password) { | |
| 2245 _showLoginError("Please enter your username and password."); | |
| 2246 return; | |
| 2247 } | |
| 2248 if (loginSubmitBtn) loginSubmitBtn.disabled = true; | |
| 2249 _clearLoginError(); | |
| 1540 try { | 2250 try { |
| 1541 const conversation = await requestJson( | 2251 const response = await fetch("/api/auth/login", { |
| 1542 `/api/conversations/${encodeURIComponent(conversationId)}`, | 2252 method: "POST", |
| 1543 { headers: {} }, | 2253 headers: { |
| 2254 "Content-Type": "application/json", | |
| 2255 "Origin": window.location.origin, | |
| 2256 }, | |
| 2257 credentials: "same-origin", | |
| 2258 body: JSON.stringify({ username, password, csrfToken: _csrfToken }), | |
| 2259 }); | |
| 2260 if (loginPasswordInput) loginPasswordInput.value = ""; | |
| 2261 if (response.status === 429) { | |
| 2262 _showLoginError("Too many attempts. Please try again later."); | |
| 2263 return; | |
| 2264 } | |
| 2265 if (!response.ok) { | |
| 2266 _showLoginError("Invalid username or password."); | |
| 2267 return; | |
| 2268 } | |
| 2269 let data = await response.json().catch(() => null); | |
| 2270 if (!data) { | |
| 2271 _csrfToken = await _fetchSession(); | |
| 2272 if (_sessionData?.kind !== "user") { | |
| 2273 loginDialog?.close(); | |
| 2274 window.location.reload(); | |
| 2275 return; | |
| 2276 } | |
| 2277 data = _sessionData; | |
| 2278 } | |
| 2279 invalidatePrincipalRequests(); | |
| 2280 loginDialog?.close(); | |
| 2281 _sessionData = { | |
| 2282 kind: "user", | |
| 2283 username: data.username || username, | |
| 2284 role: data.role || "member", | |
| 2285 mustChangePassword: Boolean(data.mustChangePassword), | |
| 2286 csrfToken: data.csrfToken, | |
| 2287 quota: null, | |
| 2288 }; | |
| 2289 _csrfToken = data.csrfToken || null; | |
| 2290 if (data.mustChangePassword) { | |
| 2291 window.location.href = "/account/password"; | |
| 2292 return; | |
| 2293 } | |
| 2294 /* Commit the returned principal before optional UI refresh work. */ | |
| 2295 renderAccountSlot(accountSlot, _sessionData); | |
| 2296 updateQuotaDisplay(quotaEl, _sessionData?.quota || null); | |
| 2297 composer?.setDisabled(false); | |
| 2298 /* Clear conversation state — guest convs may transfer to user */ | |
| 2299 currentConversationId = null; | |
| 2300 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2301 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | |
| 2302 legacyClaimId = null; | |
| 2303 _replaceUrlState(currentPanel, null); | |
| 2304 chat?.replaceMessages([]); | |
| 2305 try { | |
| 2306 await refreshArchiveFromScratch(); | |
| 2307 } catch (error) { | |
| 2308 archive?.setError(`Signed in, but archive refresh failed: ${error.message}`); | |
| 2309 } | |
| 2310 requestAnimationFrame(() => { | |
| 2311 accountSlot?.querySelector("button, a")?.focus(); | |
| 2312 }); | |
| 2313 } catch { | |
| 2314 if (loginPasswordInput) loginPasswordInput.value = ""; | |
| 2315 _showLoginError("Sign-in failed. Please try again."); | |
| 2316 } finally { | |
| 2317 if (loginSubmitBtn) loginSubmitBtn.disabled = false; | |
| 2318 } | |
| 2319 }); | |
| 2320 | |
| 2321 /* Handle logout */ | |
| 2322 _sessionRefreshCallback = () => { | |
| 2323 /* Account changed or session lost — clear active state */ | |
| 2324 invalidatePrincipalRequests(); | |
| 2325 if (activeController) { | |
| 2326 activeController.abort(); | |
| 2327 activeController = null; | |
| 2328 } | |
| 2329 currentConversationId = null; | |
| 2330 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2331 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | |
| 2332 legacyClaimId = null; | |
| 2333 _replaceUrlState(currentPanel, null); | |
| 2334 chat?.replaceMessages([]); | |
| 2335 _renderedIds.clear(); | |
| 2336 archiveCursor = null; | |
| 2337 archive?.setConversations([], null); | |
| 2338 renderAccountSlot(accountSlot, _sessionData); | |
| 2339 archive?.setError("Session changed. Loading conversations..."); | |
| 2340 void refreshArchiveFromScratch(); | |
| 2341 }; | |
| 2342 window.addEventListener("mjj-logout", () => { | |
| 2343 invalidatePrincipalRequests(); | |
| 2344 if (activeController) { | |
| 2345 activeController.abort(); | |
| 2346 activeController = null; | |
| 2347 } | |
| 2348 archive?.setStreamActive(false); | |
| 2349 currentConversationId = null; | |
| 2350 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2351 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | |
| 2352 legacyClaimId = null; | |
| 2353 _replaceUrlState(currentPanel, null); | |
| 2354 chat?.replaceMessages([]); | |
| 2355 _renderedIds.clear(); | |
| 2356 archiveCursor = null; | |
| 2357 archive?.setConversations([], null); | |
| 2358 void refreshArchiveFromScratch(); | |
| 2359 void (async () => { | |
| 2360 _csrfToken = await _fetchSession(); | |
| 2361 renderAccountSlot(accountSlot, _sessionData); | |
| 2362 updateQuotaDisplay(quotaEl, _sessionData?.quota || null); | |
| 2363 if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) { | |
| 2364 composer?.setDisabled(true); | |
| 2365 } else { | |
| 2366 composer?.setDisabled(false); | |
| 2367 } | |
| 2368 })(); | |
| 2369 }); | |
| 2370 | |
| 2371 /* Session quota display */ | |
| 2372 if (_sessionData?.kind === "guest") { | |
| 2373 updateQuotaDisplay(quotaEl, _sessionData.quota || null); | |
| 2374 } | |
| 2375 | |
| 2376 /* Disable composer for forced-password-change */ | |
| 2377 const isForced = _sessionData?.kind === "user" && _sessionData?.mustChangePassword; | |
| 2378 | |
| 2379 /* ---- URL state helpers ---- */ | |
| 2380 const _buildStateUrl = (panel, convId) => { | |
| 2381 const url = new URL(window.location.href); | |
| 2382 if (panel && VALID_PANELS.includes(panel)) { | |
| 2383 url.searchParams.set("panel", panel); | |
| 2384 } else { | |
| 2385 url.searchParams.delete("panel"); | |
| 2386 } | |
| 2387 if (convId && UUID_REGEX.test(convId)) { | |
| 2388 url.searchParams.set("conversation", convId); | |
| 2389 } else { | |
| 2390 url.searchParams.delete("conversation"); | |
| 2391 } | |
| 2392 return url; | |
| 2393 }; | |
| 2394 | |
| 2395 const _pushUrlState = (panel, convId) => { | |
| 2396 history.pushState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId)); | |
| 2397 }; | |
| 2398 | |
| 2399 const _replaceUrlState = (panel, convId) => { | |
| 2400 history.replaceState({ panel, conversation: convId || null }, "", _buildStateUrl(panel, convId)); | |
| 2401 }; | |
| 2402 | |
| 2403 /* ---- Panel selection: shows archive or preview in utility ---- */ | |
| 2404 const utilityEl = shell?.querySelector(".jrpg-utility"); | |
| 2405 | |
| 2406 const selectPanel = (panel, pushHistory = true) => { | |
| 2407 const validPanel = VALID_PANELS.includes(panel) ? panel : "resume"; | |
| 2408 currentPanel = validPanel; | |
| 2409 for (const btn of (shell?.querySelectorAll("button[data-preview]") || [])) { | |
| 2410 btn.setAttribute("aria-pressed", String(btn.dataset.preview === validPanel)); | |
| 2411 } | |
| 2412 if (validPanel === "conversations") { | |
| 2413 if (archive) archive.hidden = false; | |
| 2414 if (preview) preview.hidden = true; | |
| 2415 if (utilityEl) utilityEl.setAttribute("aria-label", "Conversations"); | |
| 2416 } else { | |
| 2417 if (archive) archive.hidden = true; | |
| 2418 if (preview) preview.hidden = false; | |
| 2419 preview?.show(validPanel); | |
| 2420 if (utilityEl) utilityEl.setAttribute("aria-label", "Selected destination"); | |
| 2421 } | |
| 2422 if (pushHistory) _pushUrlState(validPanel, currentConversationId); | |
| 2423 }; | |
| 2424 | |
| 2425 /* ---- Archive close button: navigate back to resume panel ---- */ | |
| 2426 shell?.addEventListener("mjj-archive-toggle", () => { | |
| 2427 selectPanel("resume"); | |
| 2428 }); | |
| 2429 | |
| 2430 shell?.addEventListener("mjj-archive-new", () => { | |
| 2431 if (activeController) return; | |
| 2432 currentConversationId = null; | |
| 2433 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2434 _pushUrlState(currentPanel, null); | |
| 2435 chat?.replaceMessages([]); | |
| 2436 archive?.setCurrentId(null); | |
| 2437 composer?.querySelector("textarea")?.focus(); | |
| 2438 }); | |
| 2439 | |
| 2440 /* ---- Popstate: re-apply panel and conversation on back/forward ---- */ | |
| 2441 /* _applyPopState always increments openSeq so any pending archive-open | |
| 2442 fetch (including a transition to no conversation) cannot overwrite the | |
| 2443 state the user navigated to (Fix 2). */ | |
| 2444 const _applyPopState = async (seq, popPanel, popConvId) => { | |
| 2445 const localSeq = ++openSeq; | |
| 2446 const epoch = principalEpoch; | |
| 2447 | |
| 2448 selectPanel(popPanel, false); | |
| 2449 | |
| 2450 if (popConvId && popConvId !== currentConversationId) { | |
| 2451 try { | |
| 2452 const conversation = await requestJson(`/api/conversations/${encodeURIComponent(popConvId)}`); | |
| 2453 if (seq !== popSeq || localSeq !== openSeq || | |
| 2454 epoch !== principalEpoch) return; | |
| 2455 currentConversationId = popConvId; | |
| 2456 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, popConvId); | |
| 2457 chat?.replaceMessages(conversation.turns); | |
| 2458 archive?.setCurrentId(popConvId); | |
| 2459 } catch { | |
| 2460 if (seq !== popSeq || localSeq !== openSeq || | |
| 2461 epoch !== principalEpoch) return; | |
| 2462 currentConversationId = null; | |
| 2463 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2464 chat?.replaceMessages([]); | |
| 2465 archive?.setCurrentId(null); | |
| 2466 history.replaceState( | |
| 2467 { panel: popPanel, conversation: null }, | |
| 2468 "", | |
| 2469 _buildStateUrl(popPanel, null), | |
| 2470 ); | |
| 2471 } | |
| 2472 } else if (!popConvId && currentConversationId) { | |
| 2473 currentConversationId = null; | |
| 2474 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2475 chat?.replaceMessages([]); | |
| 2476 archive?.setCurrentId(null); | |
| 2477 } | |
| 2478 }; | |
| 2479 | |
| 2480 /* When popstate fires while a stream is active the URL has already changed. | |
| 2481 Queue the parsed destination, abort the stream, and apply exactly once in | |
| 2482 the stream's finally block so URL and UI always converge (Fix 1). */ | |
| 2483 window.addEventListener("popstate", async event => { | |
| 2484 const seq = ++popSeq; | |
| 2485 ++openSeq; | |
| 2486 const state = event.state || {}; | |
| 2487 const popPanel = VALID_PANELS.includes(state.panel) ? state.panel : "resume"; | |
| 2488 const popConvId = (state.conversation && UUID_REGEX.test(state.conversation)) | |
| 2489 ? state.conversation : null; | |
| 2490 | |
| 2491 if (activeController) { | |
| 2492 _pendingPopState = { seq, popPanel, popConvId }; | |
| 2493 activeController.abort(); | |
| 2494 return; | |
| 2495 } | |
| 2496 | |
| 2497 await _applyPopState(seq, popPanel, popConvId); | |
| 2498 }); | |
| 2499 | |
| 2500 shell?.addEventListener("mjj-archive-rename", async event => { | |
| 2501 const { id, newTitle, trigger } = event.detail; | |
| 2502 if (activeController) { trigger?.focus(); return; } | |
| 2503 try { | |
| 2504 const response = await mutatingFetch( | |
| 2505 `/api/conversations/${encodeURIComponent(id)}`, | |
| 2506 { | |
| 2507 method: "PATCH", | |
| 2508 headers: { "Content-Type": "application/json" }, | |
| 2509 body: JSON.stringify({ title: newTitle }), | |
| 2510 }, | |
| 1544 ); | 2511 ); |
| 2512 if (!response.ok) { | |
| 2513 const body = await response.json().catch(() => ({})); | |
| 2514 throw new Error(body.error?.message || `Rename failed (${response.status})`); | |
| 2515 } | |
| 2516 archive?.updateConversation({ id, title: newTitle, turn_count: null }); | |
| 2517 } catch (error) { | |
| 2518 archive?.setError(`Rename failed: ${error.message}`); | |
| 2519 } finally { | |
| 2520 trigger?.focus(); | |
| 2521 } | |
| 2522 }); | |
| 2523 | |
| 2524 shell?.addEventListener("mjj-archive-open", async event => { | |
| 2525 const { id } = event.detail; | |
| 2526 if (id === currentConversationId) return; | |
| 2527 if (activeController) return; | |
| 2528 const seq = ++openSeq; | |
| 2529 const epoch = principalEpoch; | |
| 2530 try { | |
| 2531 const conversation = await requestJson(`/api/conversations/${encodeURIComponent(id)}`); | |
| 2532 if (seq !== openSeq || epoch !== principalEpoch) return; | |
| 2533 currentConversationId = id; | |
| 2534 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, id); | |
| 2535 _pushUrlState(currentPanel, id); | |
| 1545 chat?.replaceMessages(conversation.turns); | 2536 chat?.replaceMessages(conversation.turns); |
| 2537 archive?.setCurrentId(id); | |
| 2538 } catch (error) { | |
| 2539 if (seq !== openSeq || epoch !== principalEpoch) return; | |
| 2540 archive?.setError(`Unable to open: ${error.message}`); | |
| 2541 } | |
| 2542 }); | |
| 2543 | |
| 2544 shell?.addEventListener("mjj-archive-delete", async event => { | |
| 2545 const { id, trigger } = event.detail; | |
| 2546 if (activeController) { trigger?.focus(); return; } /* block while streaming */ | |
| 2547 try { | |
| 2548 const response = await mutatingFetch( | |
| 2549 `/api/conversations/${encodeURIComponent(id)}`, | |
| 2550 { method: "DELETE" }, | |
| 2551 ); | |
| 2552 if (response.ok || response.status === 404) { | |
| 2553 /* Focus adjacent archive item or New button before removing the node */ | |
| 2554 const focusTarget = archive?.adjacentItemOrNewButton(id); | |
| 2555 _renderedIds.delete(id); | |
| 2556 archive?.removeConversation(id); | |
| 2557 if (id === currentConversationId) { | |
| 2558 currentConversationId = null; | |
| 2559 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2560 _replaceUrlState(currentPanel, null); | |
| 2561 chat?.replaceMessages([]); | |
| 2562 } | |
| 2563 /* Focus surviving element — never the detached trigger */ | |
| 2564 requestAnimationFrame(() => { | |
| 2565 (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus(); | |
| 2566 }); | |
| 2567 } else { | |
| 2568 const body = await response.json().catch(() => ({})); | |
| 2569 archive?.setError(`Delete failed: ${body.error?.message || response.status}`); | |
| 2570 } | |
| 2571 } catch (error) { | |
| 2572 archive?.setError(`Delete failed: ${error.message}`); | |
| 2573 /* On error, the trigger is still attached — focus it */ | |
| 2574 trigger?.focus(); | |
| 2575 } | |
| 2576 }); | |
| 2577 | |
| 2578 shell?.addEventListener("mjj-archive-load-more", async () => { | |
| 2579 if (!archiveCursor || archiveLoadInFlight) return; | |
| 2580 archiveLoadInFlight = true; | |
| 2581 const epoch = principalEpoch; | |
| 2582 archive?.setLoadMoreDisabled(true); | |
| 2583 try { | |
| 2584 const data = await fetchConversationList(archiveCursor); | |
| 2585 if (epoch !== principalEpoch) return; | |
| 2586 archiveCursor = data.cursor || null; | |
| 2587 const newConvs = (data.conversations || []).filter(c => !_renderedIds.has(c.id)); | |
| 2588 newConvs.forEach(c => _renderedIds.add(c.id)); | |
| 2589 archive?.setConversations(newConvs, archiveCursor, true); | |
| 2590 } catch (error) { | |
| 2591 archive?.setError(`Load failed: ${error.message}`); | |
| 2592 } finally { | |
| 2593 archiveLoadInFlight = false; | |
| 2594 archive?.setLoadMoreDisabled(false); | |
| 2595 } | |
| 2596 }); | |
| 2597 | |
| 2598 shell?.addEventListener("mjj-archive-claim", async () => { | |
| 2599 const legacyId = | |
| 2600 legacyClaimId || sessionStorage.getItem(CONVERSATION_LEGACY_KEY); | |
| 2601 if (!legacyId || _sessionData?.kind !== "user" || _claimInFlight) return; | |
| 2602 _claimInFlight = true; | |
| 2603 archive?.showLegacyClaim("Claiming prior conversation...", false); | |
| 2604 let _claimOutcome = null; | |
| 2605 try { | |
| 2606 const response = await mutatingFetch( | |
| 2607 "/api/conversations/claim", | |
| 2608 { | |
| 2609 method: "POST", | |
| 2610 headers: { "Content-Type": "application/json" }, | |
| 2611 body: JSON.stringify({ conversationId: legacyId }), | |
| 2612 }, | |
| 2613 ); | |
| 2614 if (response.ok) { | |
| 2615 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | |
| 2616 legacyClaimId = null; | |
| 2617 _claimOutcome = "success"; | |
| 2618 } else if (response.status === 409) { | |
| 2619 /* Definitive non-legacy conflict: clear key, no retry needed */ | |
| 2620 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | |
| 2621 legacyClaimId = null; | |
| 2622 _claimOutcome = "conflict"; | |
| 2623 } else { | |
| 2624 /* Retryable (401, 403, 429, 5xx): retain key */ | |
| 2625 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId); | |
| 2626 legacyClaimId = legacyId; | |
| 2627 _claimOutcome = "retry"; | |
| 2628 } | |
| 1546 } catch { | 2629 } catch { |
| 1547 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2630 /* Network error: retain key */ |
| 1548 conversationId = null; | 2631 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, legacyId); |
| 1549 } | 2632 legacyClaimId = legacyId; |
| 2633 _claimOutcome = "network-error"; | |
| 2634 } finally { | |
| 2635 /* Reset guard BEFORE re-rendering so the retry button is immediately usable */ | |
| 2636 _claimInFlight = false; | |
| 2637 if (_claimOutcome === "success") { | |
| 2638 void refreshArchiveFromScratch(); | |
| 2639 } else if (_claimOutcome === "conflict") { | |
| 2640 archive?.showLegacyClaim("Prior conversation already claimed.", false); | |
| 2641 } else if (_claimOutcome === "retry") { | |
| 2642 selectPanel("conversations", false); | |
| 2643 archive?.showLegacyClaim("Claim failed. Try again. "); | |
| 2644 } else if (_claimOutcome === "network-error") { | |
| 2645 selectPanel("conversations", false); | |
| 2646 archive?.showLegacyClaim("Claim failed. Check connection. "); | |
| 2647 } | |
| 2648 } | |
| 2649 }); | |
| 2650 | |
| 2651 async function refreshArchiveFromScratch() { | |
| 2652 if (!archive) return; | |
| 2653 const epoch = principalEpoch; | |
| 2654 archive.setLoading(true); | |
| 2655 _renderedIds.clear(); | |
| 2656 try { | |
| 2657 const data = await fetchConversationList(null); | |
| 2658 if (epoch !== principalEpoch) return; | |
| 2659 archiveCursor = data.cursor || null; | |
| 2660 const convs = data.conversations || []; | |
| 2661 convs.forEach(c => _renderedIds.add(c.id)); | |
| 2662 archive.setConversations(convs, archiveCursor); | |
| 2663 archive.setCurrentId(currentConversationId); | |
| 2664 } catch (error) { | |
| 2665 archive.setError(`Archive error: ${error.message}`); | |
| 2666 } | |
| 2667 } | |
| 2668 | |
| 2669 async function refreshArchiveHead() { | |
| 2670 if (!archive) return; | |
| 2671 const epoch = principalEpoch; | |
| 2672 try { | |
| 2673 const data = await fetchConversationList(null); | |
| 2674 if (epoch !== principalEpoch) return; | |
| 2675 const fresh = data.conversations || []; | |
| 2676 for (const conv of fresh) archive.updateConversation(conv); | |
| 2677 if (fresh.length > 0) archive.setCurrentId(currentConversationId); | |
| 2678 } catch { /* silent refresh failure */ } | |
| 2679 } | |
| 2680 | |
| 2681 /* ---- Parse and validate URL params at startup ---- */ | |
| 2682 /* Track whether ?conversation was explicitly present (even if malformed) | |
| 2683 so we never migrate sessionStorage when the param was given but invalid. */ | |
| 2684 let _convParamWasExplicit = false; | |
| 2685 { | |
| 2686 const initParams = new URLSearchParams(window.location.search); | |
| 2687 const rawPanel = initParams.get("panel"); | |
| 2688 const rawConvId = initParams.get("conversation"); | |
| 2689 _convParamWasExplicit = rawConvId !== null; | |
| 2690 const validPanel = VALID_PANELS.includes(rawPanel) ? rawPanel : null; | |
| 2691 const validConvId = (rawConvId && UUID_REGEX.test(rawConvId)) ? rawConvId : null; | |
| 2692 /* Remove invalid params with replaceState */ | |
| 2693 if ((rawPanel && !validPanel) || (rawConvId && !validConvId)) { | |
| 2694 const normalUrl = new URL(window.location.href); | |
| 2695 if (!validPanel) normalUrl.searchParams.delete("panel"); | |
| 2696 if (!validConvId) normalUrl.searchParams.delete("conversation"); | |
| 2697 history.replaceState( | |
| 2698 { panel: validPanel, conversation: validConvId }, | |
| 2699 "", | |
| 2700 normalUrl, | |
| 2701 ); | |
| 2702 } | |
| 2703 currentPanel = validPanel || "resume"; | |
| 2704 } | |
| 2705 | |
| 2706 /* ---- Load archive and restore conversation from URL or sessionStorage ---- */ | |
| 2707 const startupEpoch = principalEpoch; | |
| 2708 const ensureStartupPrincipal = () => { | |
| 2709 if (startupEpoch !== principalEpoch) { | |
| 2710 const error = new Error("Principal changed during startup"); | |
| 2711 error.name = "StalePrincipalError"; | |
| 2712 throw error; | |
| 2713 } | |
| 2714 }; | |
| 2715 archive?.setLoading(true); | |
| 2716 try { | |
| 2717 const data = await fetchConversationList(null); | |
| 2718 ensureStartupPrincipal(); | |
| 2719 archiveCursor = data.cursor || null; | |
| 2720 const conversations = data.conversations || []; | |
| 2721 conversations.forEach(c => _renderedIds.add(c.id)); | |
| 2722 archive?.setConversations(conversations, archiveCursor); | |
| 2723 | |
| 2724 /* Determine target conversation: URL param is canonical; migrate sessionStorage once */ | |
| 2725 const initParams2 = new URLSearchParams(window.location.search); | |
| 2726 const urlConvId = initParams2.get("conversation"); | |
| 2727 const urlConvValid = urlConvId && UUID_REGEX.test(urlConvId) ? urlConvId : null; | |
| 2728 | |
| 2729 let resolvedConvId = urlConvValid; | |
| 2730 let migratedFromStorage = false; | |
| 2731 if (!resolvedConvId && !_convParamWasExplicit) { | |
| 2732 const stored = sessionStorage.getItem(CONVERSATION_STORAGE_KEY); | |
| 2733 if (stored && UUID_REGEX.test(stored)) { | |
| 2734 resolvedConvId = stored; | |
| 2735 migratedFromStorage = true; | |
| 2736 } | |
| 2737 } | |
| 2738 | |
| 2739 if (resolvedConvId) { | |
| 2740 const inArchive = conversations.find(c => c.id === resolvedConvId); | |
| 2741 if (inArchive) { | |
| 2742 try { | |
| 2743 const conversation = await requestJson( | |
| 2744 `/api/conversations/${encodeURIComponent(resolvedConvId)}`, | |
| 2745 ); | |
| 2746 ensureStartupPrincipal(); | |
| 2747 currentConversationId = resolvedConvId; | |
| 2748 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); | |
| 2749 chat?.replaceMessages(conversation.turns); | |
| 2750 archive?.setCurrentId(resolvedConvId); | |
| 2751 } catch (error) { | |
| 2752 if (error.name === "StalePrincipalError" || | |
| 2753 startupEpoch !== principalEpoch) throw error; | |
| 2754 currentConversationId = null; | |
| 2755 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2756 } | |
| 2757 } else { | |
| 2758 /* Not on first page — fetch directly to verify ownership */ | |
| 2759 let ownedConversation = null; | |
| 2760 try { | |
| 2761 ownedConversation = await requestJson( | |
| 2762 `/api/conversations/${encodeURIComponent(resolvedConvId)}`, | |
| 2763 ); | |
| 2764 ensureStartupPrincipal(); | |
| 2765 } catch (error) { | |
| 2766 if (error.name === "StalePrincipalError" || | |
| 2767 startupEpoch !== principalEpoch) throw error; | |
| 2768 ownedConversation = null; | |
| 2769 } | |
| 2770 if (ownedConversation) { | |
| 2771 currentConversationId = resolvedConvId; | |
| 2772 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, resolvedConvId); | |
| 2773 chat?.replaceMessages(ownedConversation.turns); | |
| 2774 if (!_renderedIds.has(resolvedConvId)) { | |
| 2775 _renderedIds.add(resolvedConvId); | |
| 2776 archive?.addConversation({ | |
| 2777 id: resolvedConvId, | |
| 2778 title: ownedConversation.title || "Conversation", | |
| 2779 turn_count: ownedConversation.turns?.length || 0, | |
| 2780 }); | |
| 2781 } | |
| 2782 archive?.setCurrentId(resolvedConvId); | |
| 2783 } else if (_sessionData?.kind === "user") { | |
| 2784 /* Authenticated and 404 — offer legacy claim for sessionStorage migrations */ | |
| 2785 if (migratedFromStorage) { | |
| 2786 sessionStorage.setItem(CONVERSATION_LEGACY_KEY, resolvedConvId); | |
| 2787 legacyClaimId = resolvedConvId; | |
| 2788 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2789 archive?.showLegacyClaim(); | |
| 2790 if (conversations.length > 0) { | |
| 2791 currentConversationId = conversations[0].id; | |
| 2792 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); | |
| 2793 try { | |
| 2794 const conv = await requestJson( | |
| 2795 `/api/conversations/${encodeURIComponent(currentConversationId)}`, | |
| 2796 ); | |
| 2797 ensureStartupPrincipal(); | |
| 2798 chat?.replaceMessages(conv.turns); | |
| 2799 archive?.setCurrentId(currentConversationId); | |
| 2800 } catch (error) { | |
| 2801 if (error.name === "StalePrincipalError" || | |
| 2802 startupEpoch !== principalEpoch) throw error; | |
| 2803 currentConversationId = null; | |
| 2804 } | |
| 2805 } | |
| 2806 } else { | |
| 2807 /* URL-specified conv returned 404 — clear it */ | |
| 2808 currentConversationId = null; | |
| 2809 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2810 } | |
| 2811 } else { | |
| 2812 /* Guest — not owned, clear stale ID */ | |
| 2813 currentConversationId = null; | |
| 2814 if (migratedFromStorage) sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2815 } | |
| 2816 } | |
| 2817 } else if (conversations.length > 0) { | |
| 2818 /* No target conversation — open the most recent */ | |
| 2819 const most_recent = conversations[0]; | |
| 2820 try { | |
| 2821 const conversation = await requestJson( | |
| 2822 `/api/conversations/${encodeURIComponent(most_recent.id)}`, | |
| 2823 ); | |
| 2824 ensureStartupPrincipal(); | |
| 2825 currentConversationId = most_recent.id; | |
| 2826 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); | |
| 2827 chat?.replaceMessages(conversation.turns); | |
| 2828 archive?.setCurrentId(most_recent.id); | |
| 2829 } catch (error) { | |
| 2830 if (error.name === "StalePrincipalError" || | |
| 2831 startupEpoch !== principalEpoch) throw error; | |
| 2832 currentConversationId = null; | |
| 2833 } | |
| 2834 } | |
| 2835 } catch (error) { | |
| 2836 if (error.name !== "StalePrincipalError" && | |
| 2837 startupEpoch === principalEpoch) { | |
| 2838 archive?.setError(`Archive error: ${error.message}`); | |
| 2839 } | |
| 2840 /* Fall back to direct fetch of URL/stored ID */ | |
| 2841 const initParams3 = new URLSearchParams(window.location.search); | |
| 2842 const fallbackId = ( | |
| 2843 initParams3.get("conversation") || | |
| 2844 (!_convParamWasExplicit | |
| 2845 ? sessionStorage.getItem(CONVERSATION_STORAGE_KEY) | |
| 2846 : null) | |
| 2847 ) || null; | |
| 2848 if (startupEpoch === principalEpoch && | |
| 2849 fallbackId && UUID_REGEX.test(fallbackId)) { | |
| 2850 try { | |
| 2851 const conversation = await requestJson( | |
| 2852 `/api/conversations/${encodeURIComponent(fallbackId)}`, | |
| 2853 ); | |
| 2854 ensureStartupPrincipal(); | |
| 2855 currentConversationId = fallbackId; | |
| 2856 chat?.replaceMessages(conversation.turns); | |
| 2857 } catch (fallbackError) { | |
| 2858 if (fallbackError.name === "StalePrincipalError" || | |
| 2859 startupEpoch !== principalEpoch) { | |
| 2860 // The new principal owns subsequent rendering. | |
| 2861 } else { | |
| 2862 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | |
| 2863 currentConversationId = null; | |
| 2864 } | |
| 2865 } | |
| 2866 } | |
| 2867 } | |
| 2868 | |
| 2869 /* Apply startup panel and normalize URL (replaceState — not a user navigation) */ | |
| 2870 if (startupEpoch === principalEpoch) { | |
| 2871 selectPanel(currentPanel, false); | |
| 2872 _replaceUrlState(currentPanel, currentConversationId); | |
| 1550 } | 2873 } |
| 1551 | 2874 |
| 1552 shell?.addEventListener("mjj-jrpg-preview-change", event => { | 2875 shell?.addEventListener("mjj-jrpg-preview-change", event => { |
| 1553 preview?.show(event.detail.selection); | 2876 selectPanel(event.detail.selection); |
| 1554 character?.setAttribute("state", "thinking"); | 2877 character?.setAttribute("state", "thinking"); |
| 1555 window.clearTimeout(characterTimer); | 2878 window.clearTimeout(characterTimer); |
| 1556 characterTimer = window.setTimeout(() => { | 2879 characterTimer = window.setTimeout(() => { |
| 1557 character?.setAttribute("state", "default"); | 2880 character?.setAttribute("state", "default"); |
| 1558 }, 650); | 2881 }, 650); |
| 1562 activeController?.abort(); | 2885 activeController?.abort(); |
| 1563 }); | 2886 }); |
| 1564 | 2887 |
| 1565 shell?.addEventListener("mjj-jrpg-submit", async event => { | 2888 shell?.addEventListener("mjj-jrpg-submit", async event => { |
| 1566 if (activeController) return; | 2889 if (activeController) return; |
| 2890 /* Block sends when password change is required */ | |
| 2891 if (_sessionData?.kind === "user" && _sessionData?.mustChangePassword) { | |
| 2892 return; | |
| 2893 } | |
| 1567 chat?.appendMessage("June", event.detail.message); | 2894 chat?.appendMessage("June", event.detail.message); |
| 1568 composer?.setBusy(true); | 2895 composer?.setBusy(true); |
| 1569 character?.setAttribute("state", "thinking"); | 2896 character?.setAttribute("state", "thinking"); |
| 1570 window.clearTimeout(characterTimer); | 2897 window.clearTimeout(characterTimer); |
| 1571 const assistantItem = chat?.startAssistantMessage(); | 2898 const assistantItem = chat?.startAssistantMessage(); |
| 1577 activeController = new AbortController(); | 2904 activeController = new AbortController(); |
| 1578 activeController.signal.addEventListener("abort", () => { | 2905 activeController.signal.addEventListener("abort", () => { |
| 1579 assistantText.cancel(); | 2906 assistantText.cancel(); |
| 1580 }, { once: true }); | 2907 }, { once: true }); |
| 1581 | 2908 |
| 2909 /* Disable archive/account mutations while stream is active (finding #7) */ | |
| 2910 archive?.setStreamActive(true); | |
| 2911 setAccountActionsDisabled(true); | |
| 2912 | |
| 1582 try { | 2913 try { |
| 1583 const createConversation = async () => { | 2914 const createConversation = async () => { |
| 1584 const conversation = await requestJson("/api/conversations", { | 2915 const resp = await mutatingFetch("/api/conversations", { |
| 1585 method: "POST", | 2916 method: "POST", |
| 2917 headers: { "Content-Type": "application/json" }, | |
| 1586 body: JSON.stringify({ title: "Shiba Quest" }), | 2918 body: JSON.stringify({ title: "Shiba Quest" }), |
| 1587 }); | 2919 }); |
| 1588 conversationId = conversation.id; | 2920 if (!resp.ok) { |
| 1589 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, conversationId); | 2921 let message = `Create failed (${resp.status})`; |
| 2922 try { | |
| 2923 const body = await resp.json(); | |
| 2924 message = body.error?.message || message; | |
| 2925 } catch { /* keep */ } | |
| 2926 throw new Error(message); | |
| 2927 } | |
| 2928 const conversation = await resp.json(); | |
| 2929 currentConversationId = conversation.id; | |
| 2930 sessionStorage.setItem(CONVERSATION_STORAGE_KEY, currentConversationId); | |
| 2931 _pushUrlState(currentPanel, currentConversationId); | |
| 2932 if (!_renderedIds.has(currentConversationId)) { | |
| 2933 _renderedIds.add(currentConversationId); | |
| 2934 archive?.addConversation({ | |
| 2935 id: currentConversationId, | |
| 2936 title: "Shiba Quest", | |
| 2937 turn_count: 0, | |
| 2938 last_message_preview: event.detail.message.slice(0, 32), | |
| 2939 }); | |
| 2940 } | |
| 2941 archive?.setCurrentId(currentConversationId); | |
| 1590 }; | 2942 }; |
| 1591 if (!conversationId) { | 2943 |
| 2944 if (!currentConversationId) { | |
| 1592 await createConversation(); | 2945 await createConversation(); |
| 1593 } | 2946 } |
| 1594 | 2947 |
| 1595 const postTurn = () => fetch( | 2948 const postTurn = () => mutatingFetch( |
| 1596 `/api/conversations/${encodeURIComponent(conversationId)}/turns`, { | 2949 `/api/conversations/${encodeURIComponent(currentConversationId)}/turns`, |
| 2950 { | |
| 1597 method: "POST", | 2951 method: "POST", |
| 1598 headers: { "Content-Type": "application/json" }, | 2952 headers: { "Content-Type": "application/json" }, |
| 1599 body: JSON.stringify({ prompt: event.detail.message }), | 2953 body: JSON.stringify({ prompt: event.detail.message }), |
| 1600 signal: activeController.signal, | 2954 signal: activeController.signal, |
| 1601 }, | 2955 }, |
| 1602 ); | 2956 ); |
| 2957 | |
| 1603 let response = await postTurn(); | 2958 let response = await postTurn(); |
| 2959 | |
| 2960 /* Handle 404: conversation may have expired — recreate it */ | |
| 1604 if (response.status === 404) { | 2961 if (response.status === 404) { |
| 1605 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2962 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 1606 conversationId = null; | 2963 currentConversationId = null; |
| 1607 await createConversation(); | 2964 await createConversation(); |
| 1608 response = await postTurn(); | 2965 response = await postTurn(); |
| 1609 } | 2966 } |
| 2967 | |
| 2968 /* Handle 429: distinguish permanent quota from temporary capacity */ | |
| 2969 if (response.status === 429) { | |
| 2970 let errorBody = null; | |
| 2971 try { errorBody = await response.json(); } catch { /* keep */ } | |
| 2972 const code = errorBody?.error?.code || ""; | |
| 2973 const isQuotaExhausted = | |
| 2974 code === "guest_quota_turns_exhausted" || | |
| 2975 code === "guest_quota_tokens_exhausted" || | |
| 2976 code === "guest_turn_quota_exhausted" || | |
| 2977 code === "guest_token_quota_exhausted"; | |
| 2978 | |
| 2979 if (isQuotaExhausted) { | |
| 2980 const quotaState = errorBody?.error?.quota || null; | |
| 2981 updateQuotaDisplay(quotaEl, quotaState); | |
| 2982 composer?.setDisabled(true); | |
| 2983 chat?.setMessageStreaming(assistantItem, false); | |
| 2984 chat?.setMessageText( | |
| 2985 assistantItem, | |
| 2986 quotaState | |
| 2987 ? `Quota exhausted. ${formatQuotaText(quotaState)?.text || ""}` | |
| 2988 : "Quota exhausted. Try again later.", | |
| 2989 ); | |
| 2990 character?.setAttribute("state", "sad"); | |
| 2991 setFrameStatus("QUOTA", "offline"); | |
| 2992 /* Re-enable when quota resets */ | |
| 2993 const resetsAt = quotaState?.resetsAt; | |
| 2994 if (resetsAt) { | |
| 2995 const delay = Math.max(0, resetsAt * 1000 - Date.now()); | |
| 2996 window.setTimeout(() => { | |
| 2997 if (_sessionData?.kind !== "user" || !_sessionData?.mustChangePassword) { | |
| 2998 composer?.setDisabled(false); | |
| 2999 setFrameStatus("ONLINE", "online"); | |
| 3000 } | |
| 3001 }, Math.min(delay + 1000, 24 * 3600 * 1000)); | |
| 3002 } | |
| 3003 } else { | |
| 3004 /* Temporary capacity 429 — use Retry-After or default 5 s */ | |
| 3005 const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10); | |
| 3006 chat?.setMessageStreaming(assistantItem, false); | |
| 3007 chat?.setMessageText(assistantItem, "Server busy. Retrying shortly…"); | |
| 3008 character?.setAttribute("state", "sad"); | |
| 3009 setFrameStatus("BUSY", "offline"); | |
| 3010 window.setTimeout(() => { | |
| 3011 if (!activeController?.signal.aborted) { | |
| 3012 setFrameStatus("ONLINE", "online"); | |
| 3013 composer?.setDisabled(false); | |
| 3014 } | |
| 3015 }, retryAfter * 1000); | |
| 3016 } | |
| 3017 return; | |
| 3018 } | |
| 3019 | |
| 1610 if (!response.ok) { | 3020 if (!response.ok) { |
| 1611 let message = `Turn failed (${response.status})`; | 3021 let message = `Turn failed (${response.status})`; |
| 1612 try { | 3022 try { |
| 1613 const body = await response.json(); | 3023 const body = await response.json(); |
| 1614 message = body.error?.message || message; | 3024 message = body.error?.message || message; |
| 1615 } catch { | 3025 } catch { /* keep */ } |
| 1616 // Preserve the status-derived message. | |
| 1617 } | |
| 1618 throw new Error(message); | 3026 throw new Error(message); |
| 1619 } | 3027 } |
| 3028 | |
| 1620 await consumeSse(response, (eventName, data) => { | 3029 await consumeSse(response, (eventName, data) => { |
| 1621 shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", { | 3030 shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", { |
| 1622 bubbles: true, | 3031 bubbles: true, |
| 1623 detail: { | 3032 detail: { data, type: eventName }, |
| 1624 data, | |
| 1625 type: eventName, | |
| 1626 }, | |
| 1627 })); | 3033 })); |
| 1628 if (eventName === "assistant.delta") { | 3034 if (eventName === "assistant.delta") { |
| 1629 assistantText.append(data.delta || ""); | 3035 assistantText.append(data.delta || ""); |
| 1630 } else if (eventName === "assistant.completed") { | 3036 } else if (eventName === "assistant.completed") { |
| 1631 assistantText.complete(data.content); | 3037 assistantText.complete(data.content); |
| 1641 data.aborted ? "Inference turn aborted" : "Inference turn failed", | 3047 data.aborted ? "Inference turn aborted" : "Inference turn failed", |
| 1642 ); | 3048 ); |
| 1643 } | 3049 } |
| 1644 } | 3050 } |
| 1645 }); | 3051 }); |
| 3052 | |
| 1646 if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); | 3053 if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); |
| 1647 await assistantText.waitForIdle(); | 3054 await assistantText.waitForIdle(); |
| 1648 if (activeController.signal.aborted) { | 3055 if (activeController.signal.aborted) { |
| 1649 throw new DOMException("Quest cancelled", "AbortError"); | 3056 throw new DOMException("Quest cancelled", "AbortError"); |
| 1650 } | 3057 } |
| 1651 chat?.setMessageStreaming(assistantItem, false); | 3058 chat?.setMessageStreaming(assistantItem, false); |
| 1652 character?.setAttribute("state", "default"); | 3059 character?.setAttribute("state", "default"); |
| 1653 setFrameStatus("ONLINE", "online"); | 3060 setFrameStatus("ONLINE", "online"); |
| 3061 | |
| 3062 /* Refresh archive and quota after successful turn */ | |
| 3063 void refreshArchiveHead(); | |
| 3064 if (_sessionData?.kind === "guest") { | |
| 3065 _csrfToken = await _fetchSession(); | |
| 3066 updateQuotaDisplay(quotaEl, _sessionData?.quota || null); | |
| 3067 } | |
| 1654 } catch (error) { | 3068 } catch (error) { |
| 1655 const aborted = error instanceof DOMException && error.name === "AbortError"; | 3069 const aborted = error instanceof DOMException && error.name === "AbortError"; |
| 1656 assistantText.cancel(); | 3070 assistantText.cancel(); |
| 1657 chat?.setMessageStreaming(assistantItem, false); | 3071 chat?.setMessageStreaming(assistantItem, false); |
| 1658 chat?.setMessageText( | 3072 chat?.setMessageText( |
| 1663 if (!aborted) setFrameStatus("OFFLINE", "offline"); | 3077 if (!aborted) setFrameStatus("OFFLINE", "offline"); |
| 1664 } finally { | 3078 } finally { |
| 1665 composer?.setBusy(false); | 3079 composer?.setBusy(false); |
| 1666 composer?.querySelector("textarea")?.focus(); | 3080 composer?.querySelector("textarea")?.focus(); |
| 1667 activeController = null; | 3081 activeController = null; |
| 3082 /* Re-enable archive/account mutations exactly once */ | |
| 3083 archive?.setStreamActive(false); | |
| 3084 setAccountActionsDisabled(false); | |
| 3085 /* Apply popstate that was queued during the stream (Fix 1) */ | |
| 3086 if (_pendingPopState) { | |
| 3087 const pending = _pendingPopState; | |
| 3088 _pendingPopState = null; | |
| 3089 if (pending.seq === popSeq) { | |
| 3090 await _applyPopState(pending.seq, pending.popPanel, pending.popConvId); | |
| 3091 } | |
| 3092 } | |
| 1668 } | 3093 } |
| 1669 }); | 3094 }); |
| 1670 composer?.setDisabled(false); | 3095 |
| 3096 /* Enable composer unless forced password change */ | |
| 3097 if (!isForced) { | |
| 3098 composer?.setDisabled(false); | |
| 3099 } | |
| 1671 } | 3100 } |
| 1672 | 3101 |
| 1673 if (document.readyState === "loading") { | 3102 if (document.readyState === "loading") { |
| 1674 document.addEventListener("DOMContentLoaded", () => { | 3103 document.addEventListener("DOMContentLoaded", () => { |
| 1675 void initializeJrpgPage(); | 3104 void initializeJrpgPage(); |