Mercurial
comparison mrjunejune/src/jrpg/jrpg.js @ 265:056790c4fb0d
add role-aware Epi assistant prompts
Add verified June knowledge, guest/member/admin Copilot profiles, profile-isolated session recovery, animated Epi greetings, and a single authoritative runtime config workflow for inference.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 10:50:30 -0700 |
| parents | 04fee26ecce0 |
| children | 41a49c29a28f |
comparison
equal
deleted
inserted
replaced
| 264:04fee26ecce0 | 265:056790c4fb0d |
|---|---|
| 87 const ARCHIVE_PAGE_SIZE = 20; | 87 const ARCHIVE_PAGE_SIZE = 20; |
| 88 const TYPING_INTERVAL_MS = 18; | 88 const TYPING_INTERVAL_MS = 18; |
| 89 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; | 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"]); | 91 const VALID_PANELS = Object.freeze(["resume", "tools", "blog", "conversations"]); |
| 92 const ROLE_GREETINGS = Object.freeze({ | |
| 93 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.", | |
| 94 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.", | |
| 95 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.", | |
| 96 }); | |
| 92 const TOOL_ICON_NAMES = Object.freeze({ | 97 const TOOL_ICON_NAMES = Object.freeze({ |
| 93 "/notes": "repository", | 98 "/notes": "repository", |
| 94 "/tools/file_converter": "retry", | 99 "/tools/file_converter": "retry", |
| 95 "/tools/hls_player": "play", | 100 "/tools/hls_player": "play", |
| 96 "/tools/latex_editor": "file", | 101 "/tools/latex_editor": "file", |
| 112 }); | 117 }); |
| 113 SCRIPT_LOADS.set(source, loading); | 118 SCRIPT_LOADS.set(source, loading); |
| 114 return loading; | 119 return loading; |
| 115 } | 120 } |
| 116 | 121 |
| 122 function greetingForSession(sessionData) { | |
| 123 if (sessionData?.kind !== "user") return ROLE_GREETINGS.guest; | |
| 124 return sessionData.role === "admin" | |
| 125 ? ROLE_GREETINGS.admin | |
| 126 : ROLE_GREETINGS.invited; | |
| 127 } | |
| 128 | |
| 117 class StreamingTextAnimator { | 129 class StreamingTextAnimator { |
| 118 constructor(render) { | 130 constructor(render) { |
| 119 this._render = render; | 131 this._render = render; |
| 120 this._displayed = ""; | 132 this._displayed = ""; |
| 121 this._target = ""; | 133 this._target = ""; |
| 230 this._messages = this.querySelector("[data-messages]"); | 242 this._messages = this.querySelector("[data-messages]"); |
| 231 this._viewport = this.querySelector("[data-zen-viewport]"); | 243 this._viewport = this.querySelector("[data-zen-viewport]"); |
| 232 this._previousButton = this.querySelector("[data-turn-previous]"); | 244 this._previousButton = this.querySelector("[data-turn-previous]"); |
| 233 this._nextButton = this.querySelector("[data-turn-next]"); | 245 this._nextButton = this.querySelector("[data-turn-next]"); |
| 234 this._position = this.querySelector("[data-turn-position]"); | 246 this._position = this.querySelector("[data-turn-position]"); |
| 235 this._initialMessages = [...(this._messages?.children || [])].map( | 247 this._greetingText = ROLE_GREETINGS.guest; |
| 236 item => item.cloneNode(true), | 248 this._greetingAnimator = null; |
| 237 ); | |
| 238 this._turnSequence = 0; | 249 this._turnSequence = 0; |
| 239 this._activeGroup = null; | 250 this._activeGroup = null; |
| 240 this._onNavigationClick = event => { | 251 this._onNavigationClick = event => { |
| 241 const button = event.target.closest("button"); | 252 const button = event.target.closest("button"); |
| 242 if (!button || !this.contains(button) || button.disabled) return; | 253 if (!button || !this.contains(button) || button.disabled) return; |
| 252 } | 263 } |
| 253 | 264 |
| 254 appendMessage(speaker, text) { | 265 appendMessage(speaker, text) { |
| 255 let group = this._activeGroup || "start"; | 266 let group = this._activeGroup || "start"; |
| 256 if (speaker === "June") { | 267 if (speaker === "June") { |
| 268 this._greetingAnimator?.cancel(); | |
| 269 this._greetingAnimator = null; | |
| 270 const greeting = this._messages?.querySelector("[data-greeting]"); | |
| 271 if (greeting) this.setMessageStreaming(greeting, false); | |
| 257 this._turnSequence++; | 272 this._turnSequence++; |
| 258 group = `turn-${this._turnSequence}`; | 273 group = `turn-${this._turnSequence}`; |
| 259 this._activeGroup = group; | 274 this._activeGroup = group; |
| 260 } | 275 } |
| 261 return this._createMessage(speaker, text, group, true); | 276 return this._createMessage(speaker, text, group, true); |
| 277 } | |
| 278 | |
| 279 setGreeting(text, animate = true) { | |
| 280 this._greetingText = typeof text === "string" && text | |
| 281 ? text | |
| 282 : ROLE_GREETINGS.guest; | |
| 283 this._showGreeting(animate); | |
| 284 } | |
| 285 | |
| 286 _showGreeting(animate) { | |
| 287 if (!this._messages) return; | |
| 288 this._greetingAnimator?.cancel(); | |
| 289 this._greetingAnimator = null; | |
| 290 this._messages.replaceChildren(); | |
| 291 this._turnSequence = 0; | |
| 292 this._activeGroup = null; | |
| 293 const item = this._createMessage("Epi", "", "start", false); | |
| 294 if (!item) return; | |
| 295 item.dataset.greeting = "true"; | |
| 296 this._refreshTurnGroups("start"); | |
| 297 if (!animate) { | |
| 298 this.setMessageText(item, this._greetingText); | |
| 299 this.setMessageStreaming(item, false); | |
| 300 return; | |
| 301 } | |
| 302 this.setMessageStreaming(item, true); | |
| 303 const animator = new StreamingTextAnimator(value => { | |
| 304 this.setMessageText(item, value); | |
| 305 }); | |
| 306 this._greetingAnimator = animator; | |
| 307 animator.complete(this._greetingText); | |
| 308 void animator.waitForIdle().then(() => { | |
| 309 if (this._greetingAnimator !== animator) return; | |
| 310 this.setMessageStreaming(item, false); | |
| 311 this._greetingAnimator = null; | |
| 312 }); | |
| 262 } | 313 } |
| 263 | 314 |
| 264 _createMessage(speaker, text, group, select) { | 315 _createMessage(speaker, text, group, select) { |
| 265 if (!this._messages) return; | 316 if (!this._messages) return; |
| 266 const item = document.createElement("li"); | 317 const item = document.createElement("li"); |
| 277 return item; | 328 return item; |
| 278 } | 329 } |
| 279 | 330 |
| 280 replaceMessages(turns) { | 331 replaceMessages(turns) { |
| 281 if (!this._messages) return; | 332 if (!this._messages) return; |
| 333 this._greetingAnimator?.cancel(); | |
| 334 this._greetingAnimator = null; | |
| 282 this._messages.replaceChildren(); | 335 this._messages.replaceChildren(); |
| 283 this._turnSequence = 0; | 336 this._turnSequence = 0; |
| 284 this._activeGroup = null; | 337 this._activeGroup = null; |
| 285 const visibleTurns = turns.filter(turn => | 338 const visibleTurns = turns.filter(turn => |
| 286 ["user", "assistant"].includes(turn.role) | 339 ["user", "assistant"].includes(turn.role) |
| 287 ); | 340 ); |
| 288 if (!visibleTurns.length) { | 341 if (!visibleTurns.length) { |
| 289 this._messages.append( | 342 this._showGreeting(true); |
| 290 ...this._initialMessages.map(item => item.cloneNode(true)), | |
| 291 ); | |
| 292 this._refreshTurnGroups("start"); | |
| 293 return; | 343 return; |
| 294 } | 344 } |
| 295 let group = null; | 345 let group = null; |
| 296 for (const turn of visibleTurns) { | 346 for (const turn of visibleTurns) { |
| 297 if (turn.role === "user" || !group) { | 347 if (turn.role === "user" || !group) { |
| 2114 principalEpoch += 1; | 2164 principalEpoch += 1; |
| 2115 openSeq += 1; | 2165 openSeq += 1; |
| 2116 popSeq += 1; | 2166 popSeq += 1; |
| 2117 archiveCursor = null; | 2167 archiveCursor = null; |
| 2118 }; | 2168 }; |
| 2169 chat?.setGreeting(greetingForSession(_sessionData), true); | |
| 2119 | 2170 |
| 2120 /* Disable composer until fully initialized */ | 2171 /* Disable composer until fully initialized */ |
| 2121 composer?.setDisabled(true); | 2172 composer?.setDisabled(true); |
| 2122 | 2173 |
| 2123 const setFrameStatus = (label, state, title = "") => { | 2174 const setFrameStatus = (label, state, title = "") => { |
| 2299 currentConversationId = null; | 2350 currentConversationId = null; |
| 2300 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2351 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 2301 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | 2352 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); |
| 2302 legacyClaimId = null; | 2353 legacyClaimId = null; |
| 2303 _replaceUrlState(currentPanel, null); | 2354 _replaceUrlState(currentPanel, null); |
| 2304 chat?.replaceMessages([]); | 2355 chat?.setGreeting(greetingForSession(_sessionData), true); |
| 2305 try { | 2356 try { |
| 2306 await refreshArchiveFromScratch(); | 2357 await refreshArchiveFromScratch(); |
| 2307 } catch (error) { | 2358 } catch (error) { |
| 2308 archive?.setError(`Signed in, but archive refresh failed: ${error.message}`); | 2359 archive?.setError(`Signed in, but archive refresh failed: ${error.message}`); |
| 2309 } | 2360 } |
| 2329 currentConversationId = null; | 2380 currentConversationId = null; |
| 2330 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2381 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 2331 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | 2382 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); |
| 2332 legacyClaimId = null; | 2383 legacyClaimId = null; |
| 2333 _replaceUrlState(currentPanel, null); | 2384 _replaceUrlState(currentPanel, null); |
| 2334 chat?.replaceMessages([]); | 2385 chat?.setGreeting(greetingForSession(_sessionData), true); |
| 2335 _renderedIds.clear(); | 2386 _renderedIds.clear(); |
| 2336 archiveCursor = null; | 2387 archiveCursor = null; |
| 2337 archive?.setConversations([], null); | 2388 archive?.setConversations([], null); |
| 2338 renderAccountSlot(accountSlot, _sessionData); | 2389 renderAccountSlot(accountSlot, _sessionData); |
| 2339 archive?.setError("Session changed. Loading conversations..."); | 2390 archive?.setError("Session changed. Loading conversations..."); |
| 2349 currentConversationId = null; | 2400 currentConversationId = null; |
| 2350 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2401 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 2351 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); | 2402 sessionStorage.removeItem(CONVERSATION_LEGACY_KEY); |
| 2352 legacyClaimId = null; | 2403 legacyClaimId = null; |
| 2353 _replaceUrlState(currentPanel, null); | 2404 _replaceUrlState(currentPanel, null); |
| 2354 chat?.replaceMessages([]); | 2405 chat?.setGreeting(ROLE_GREETINGS.guest, true); |
| 2355 _renderedIds.clear(); | 2406 _renderedIds.clear(); |
| 2356 archiveCursor = null; | 2407 archiveCursor = null; |
| 2357 archive?.setConversations([], null); | 2408 archive?.setConversations([], null); |
| 2358 void refreshArchiveFromScratch(); | 2409 void refreshArchiveFromScratch(); |
| 2359 void (async () => { | 2410 void (async () => { |
| 2430 shell?.addEventListener("mjj-archive-new", () => { | 2481 shell?.addEventListener("mjj-archive-new", () => { |
| 2431 if (activeController) return; | 2482 if (activeController) return; |
| 2432 currentConversationId = null; | 2483 currentConversationId = null; |
| 2433 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2484 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 2434 _pushUrlState(currentPanel, null); | 2485 _pushUrlState(currentPanel, null); |
| 2435 chat?.replaceMessages([]); | 2486 chat?.setGreeting(greetingForSession(_sessionData), true); |
| 2436 archive?.setCurrentId(null); | 2487 archive?.setCurrentId(null); |
| 2437 composer?.querySelector("textarea")?.focus(); | 2488 composer?.querySelector("textarea")?.focus(); |
| 2438 }); | 2489 }); |
| 2439 | 2490 |
| 2440 /* ---- Popstate: re-apply panel and conversation on back/forward ---- */ | 2491 /* ---- Popstate: re-apply panel and conversation on back/forward ---- */ |
| 2459 } catch { | 2510 } catch { |
| 2460 if (seq !== popSeq || localSeq !== openSeq || | 2511 if (seq !== popSeq || localSeq !== openSeq || |
| 2461 epoch !== principalEpoch) return; | 2512 epoch !== principalEpoch) return; |
| 2462 currentConversationId = null; | 2513 currentConversationId = null; |
| 2463 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2514 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 2464 chat?.replaceMessages([]); | 2515 chat?.setGreeting(greetingForSession(_sessionData), true); |
| 2465 archive?.setCurrentId(null); | 2516 archive?.setCurrentId(null); |
| 2466 history.replaceState( | 2517 history.replaceState( |
| 2467 { panel: popPanel, conversation: null }, | 2518 { panel: popPanel, conversation: null }, |
| 2468 "", | 2519 "", |
| 2469 _buildStateUrl(popPanel, null), | 2520 _buildStateUrl(popPanel, null), |
| 2470 ); | 2521 ); |
| 2471 } | 2522 } |
| 2472 } else if (!popConvId && currentConversationId) { | 2523 } else if (!popConvId && currentConversationId) { |
| 2473 currentConversationId = null; | 2524 currentConversationId = null; |
| 2474 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2525 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 2475 chat?.replaceMessages([]); | 2526 chat?.setGreeting(greetingForSession(_sessionData), true); |
| 2476 archive?.setCurrentId(null); | 2527 archive?.setCurrentId(null); |
| 2477 } | 2528 } |
| 2478 }; | 2529 }; |
| 2479 | 2530 |
| 2480 /* When popstate fires while a stream is active the URL has already changed. | 2531 /* When popstate fires while a stream is active the URL has already changed. |
| 2556 archive?.removeConversation(id); | 2607 archive?.removeConversation(id); |
| 2557 if (id === currentConversationId) { | 2608 if (id === currentConversationId) { |
| 2558 currentConversationId = null; | 2609 currentConversationId = null; |
| 2559 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); | 2610 sessionStorage.removeItem(CONVERSATION_STORAGE_KEY); |
| 2560 _replaceUrlState(currentPanel, null); | 2611 _replaceUrlState(currentPanel, null); |
| 2561 chat?.replaceMessages([]); | 2612 chat?.setGreeting(greetingForSession(_sessionData), true); |
| 2562 } | 2613 } |
| 2563 /* Focus surviving element — never the detached trigger */ | 2614 /* Focus surviving element — never the detached trigger */ |
| 2564 requestAnimationFrame(() => { | 2615 requestAnimationFrame(() => { |
| 2565 (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus(); | 2616 (focusTarget || archive?.querySelector("[data-archive-new]"))?.focus(); |
| 2566 }); | 2617 }); |