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 });