Mercurial
comparison mrjunejune/src/jrpg/jrpg.js @ 261:b401627fc49e
Add JRPG mock flows and interactive previews
Add scripted mock SSE commands, custom event forwarding, animated chat turns, full-height message navigation, and a cyberpunk resume dossier.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Wed, 05 Aug 2026 20:38:32 -0700 |
| parents | 1f9877b637e9 |
| children | 0f45474c1b1a |
comparison
equal
deleted
inserted
replaced
| 260:1f9877b637e9 | 261:b401627fc49e |
|---|---|
| 18 copy: "Experience, projects, and the systems I have helped build.", | 18 copy: "Experience, projects, and the systems I have helped build.", |
| 19 kicker: "CHARACTER RECORD", | 19 kicker: "CHARACTER RECORD", |
| 20 title: "Resume", | 20 title: "Resume", |
| 21 type: "Profile", | 21 type: "Profile", |
| 22 url: "/resume", | 22 url: "/resume", |
| 23 works: [ | |
| 24 { | |
| 25 detail: "Agentic execution", | |
| 26 label: "Copilot Tasks", | |
| 27 url: "https://www.microsoft.com/en-us/microsoft-copilot/blog/2026/02/26/copilot-tasks-from-answers-to-actions/", | |
| 28 }, | |
| 29 { | |
| 30 detail: "AI platform", | |
| 31 label: "Copilot SuperApp", | |
| 32 url: "https://www.cio.com/article/3977098/microsoft-doubles-down-on-multi-model-ai-as-it-builds-a-copilot-super-app.html", | |
| 33 }, | |
| 34 { | |
| 35 detail: "Build 2026", | |
| 36 label: "Code", | |
| 37 url: "https://news.microsoft.com/build-2026/", | |
| 38 }, | |
| 39 { | |
| 40 detail: "Personal agent", | |
| 41 label: "Autopilot", | |
| 42 url: "https://www.microsoft.com/en-us/microsoft-365/blog/2026/06/02/introducing-microsoft-scout-your-always-on-personal-agent/", | |
| 43 }, | |
| 44 { | |
| 45 detail: "Ads systems", | |
| 46 label: "Meta", | |
| 47 url: "https://www.meta.com/", | |
| 48 }, | |
| 49 { | |
| 50 detail: "Workspace platform", | |
| 51 label: "Google", | |
| 52 url: "https://www.google.com/", | |
| 53 }, | |
| 54 ], | |
| 23 }, | 55 }, |
| 24 tools: { | 56 tools: { |
| 25 copy: "Small, focused utilities for writing, media, and experimentation.", | 57 copy: "Small, focused utilities for writing, media, and experimentation.", |
| 26 kicker: "ITEM INVENTORY", | 58 kicker: "ITEM INVENTORY", |
| 27 title: "Tools", | 59 title: "Tools", |
| 28 type: "Utilities", | 60 type: "Utilities", |
| 29 url: "/tools", | 61 url: "/tools", |
| 62 works: [ | |
| 63 { detail: "Writing", label: "Markdown", url: "/tools/markdown_to_html" }, | |
| 64 { detail: "Media", label: "Converter", url: "/tools/file_converter" }, | |
| 65 { detail: "Streaming", label: "HLS Player", url: "/tools/hls_player" }, | |
| 66 ], | |
| 30 }, | 67 }, |
| 31 blog: { | 68 blog: { |
| 32 copy: "Notes from building systems, games, web tools, and curious prototypes.", | 69 copy: "Notes from building systems, games, web tools, and curious prototypes.", |
| 33 kicker: "QUEST ARCHIVE", | 70 kicker: "QUEST ARCHIVE", |
| 34 title: "Blogs", | 71 title: "Blogs", |
| 35 type: "Writing", | 72 type: "Writing", |
| 36 url: "/blog", | 73 url: "/blog", |
| 74 works: [ | |
| 75 { detail: "Archive", label: "All posts", url: "/blog" }, | |
| 76 ], | |
| 37 }, | 77 }, |
| 38 }); | 78 }); |
| 39 | 79 |
| 40 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; | 80 const CONVERSATION_STORAGE_KEY = "mjj-jrpg-conversation-id"; |
| 81 const TYPING_INTERVAL_MS = 18; | |
| 82 | |
| 83 class StreamingTextAnimator { | |
| 84 constructor(render) { | |
| 85 this._render = render; | |
| 86 this._displayed = ""; | |
| 87 this._target = ""; | |
| 88 this._timer = 0; | |
| 89 this._waiters = []; | |
| 90 this._reduceMotion = window.matchMedia( | |
| 91 "(prefers-reduced-motion: reduce)", | |
| 92 ).matches; | |
| 93 } | |
| 94 | |
| 95 append(text) { | |
| 96 if (typeof text !== "string" || !text) return; | |
| 97 this._target += text; | |
| 98 this._schedule(); | |
| 99 } | |
| 100 | |
| 101 complete(text) { | |
| 102 if (typeof text === "string") { | |
| 103 this._target = text; | |
| 104 if (!this._target.startsWith(this._displayed)) { | |
| 105 let shared = 0; | |
| 106 while ( | |
| 107 shared < this._target.length && | |
| 108 shared < this._displayed.length && | |
| 109 this._target[shared] === this._displayed[shared] | |
| 110 ) { | |
| 111 shared++; | |
| 112 } | |
| 113 this._displayed = this._displayed.slice(0, shared); | |
| 114 this._render(this._displayed); | |
| 115 } | |
| 116 } | |
| 117 this._schedule(); | |
| 118 } | |
| 119 | |
| 120 waitForIdle() { | |
| 121 if (!this._timer && this._displayed === this._target) { | |
| 122 return Promise.resolve(); | |
| 123 } | |
| 124 return new Promise(resolve => { | |
| 125 this._waiters.push(resolve); | |
| 126 }); | |
| 127 } | |
| 128 | |
| 129 cancel() { | |
| 130 window.clearTimeout(this._timer); | |
| 131 this._timer = 0; | |
| 132 this._target = this._displayed; | |
| 133 this._settle(); | |
| 134 } | |
| 135 | |
| 136 _schedule() { | |
| 137 if (this._reduceMotion) { | |
| 138 this._displayed = this._target; | |
| 139 this._render(this._displayed); | |
| 140 this._settle(); | |
| 141 return; | |
| 142 } | |
| 143 if (this._timer || this._displayed === this._target) return; | |
| 144 this._timer = window.setTimeout(() => this._tick(), TYPING_INTERVAL_MS); | |
| 145 } | |
| 146 | |
| 147 _tick() { | |
| 148 this._timer = 0; | |
| 149 const remaining = this._target.length - this._displayed.length; | |
| 150 if (remaining <= 0) { | |
| 151 this._settle(); | |
| 152 return; | |
| 153 } | |
| 154 const amount = Math.max(1, Math.ceil(remaining / 80)); | |
| 155 this._displayed = this._target.slice( | |
| 156 0, | |
| 157 this._displayed.length + amount, | |
| 158 ); | |
| 159 this._render(this._displayed); | |
| 160 if (this._displayed === this._target) this._settle(); | |
| 161 else this._schedule(); | |
| 162 } | |
| 163 | |
| 164 _settle() { | |
| 165 if (this._timer || this._displayed !== this._target) return; | |
| 166 const waiters = this._waiters.splice(0); | |
| 167 for (const resolve of waiters) resolve(); | |
| 168 } | |
| 169 } | |
| 41 | 170 |
| 42 class MjjJrpgCharacter extends HTMLElement { | 171 class MjjJrpgCharacter extends HTMLElement { |
| 43 static get observedAttributes() { | 172 static get observedAttributes() { |
| 44 return ["state"]; | 173 return ["state"]; |
| 45 } | 174 } |
| 64 | 193 |
| 65 class MjjJrpgChat extends HTMLElement { | 194 class MjjJrpgChat extends HTMLElement { |
| 66 connectedCallback() { | 195 connectedCallback() { |
| 67 this._messages = this.querySelector("[data-messages]"); | 196 this._messages = this.querySelector("[data-messages]"); |
| 68 this._viewport = this.querySelector("[data-zen-viewport]"); | 197 this._viewport = this.querySelector("[data-zen-viewport]"); |
| 198 this._previousButton = this.querySelector("[data-turn-previous]"); | |
| 199 this._nextButton = this.querySelector("[data-turn-next]"); | |
| 200 this._position = this.querySelector("[data-turn-position]"); | |
| 201 this._initialMessages = [...(this._messages?.children || [])].map( | |
| 202 item => item.cloneNode(true), | |
| 203 ); | |
| 204 this._turnSequence = 0; | |
| 205 this._activeGroup = null; | |
| 206 this._onNavigationClick = event => { | |
| 207 const button = event.target.closest("button"); | |
| 208 if (!button || !this.contains(button) || button.disabled) return; | |
| 209 if (button === this._previousButton) this._moveTurn(-1); | |
| 210 else if (button === this._nextButton) this._moveTurn(1); | |
| 211 }; | |
| 212 this.addEventListener("click", this._onNavigationClick); | |
| 213 this._refreshTurnGroups("start"); | |
| 214 } | |
| 215 | |
| 216 disconnectedCallback() { | |
| 217 this.removeEventListener("click", this._onNavigationClick); | |
| 69 } | 218 } |
| 70 | 219 |
| 71 appendMessage(speaker, text) { | 220 appendMessage(speaker, text) { |
| 221 let group = this._activeGroup || "start"; | |
| 222 if (speaker === "June") { | |
| 223 this._turnSequence++; | |
| 224 group = `turn-${this._turnSequence}`; | |
| 225 this._activeGroup = group; | |
| 226 } | |
| 227 return this._createMessage(speaker, text, group, true); | |
| 228 } | |
| 229 | |
| 230 _createMessage(speaker, text, group, select) { | |
| 72 if (!this._messages) return; | 231 if (!this._messages) return; |
| 73 const item = document.createElement("li"); | 232 const item = document.createElement("li"); |
| 74 const name = document.createElement("strong"); | 233 const name = document.createElement("strong"); |
| 75 const copy = document.createElement("p"); | 234 const copy = document.createElement("p"); |
| 76 item.className = "jrpg-message"; | 235 item.className = "jrpg-message"; |
| 77 item.dataset.speaker = speaker; | 236 item.dataset.speaker = speaker; |
| 237 item.dataset.turnGroup = group; | |
| 78 name.textContent = speaker; | 238 name.textContent = speaker; |
| 79 copy.textContent = text; | 239 copy.textContent = text; |
| 80 item.append(name, copy); | 240 item.append(name, copy); |
| 81 this._messages.append(item); | 241 this._messages.append(item); |
| 82 this.scrollEnd(); | 242 this._refreshTurnGroups(select ? group : this._currentGroup); |
| 83 return item; | 243 return item; |
| 84 } | 244 } |
| 85 | 245 |
| 86 replaceMessages(turns) { | 246 replaceMessages(turns) { |
| 87 if (!this._messages) return; | 247 if (!this._messages) return; |
| 88 this._messages.replaceChildren(); | 248 this._messages.replaceChildren(); |
| 89 for (const turn of turns) { | 249 this._turnSequence = 0; |
| 90 if (!["user", "assistant"].includes(turn.role)) continue; | 250 this._activeGroup = null; |
| 91 this.appendMessage( | 251 const visibleTurns = turns.filter(turn => |
| 252 ["user", "assistant"].includes(turn.role) | |
| 253 ); | |
| 254 if (!visibleTurns.length) { | |
| 255 this._messages.append( | |
| 256 ...this._initialMessages.map(item => item.cloneNode(true)), | |
| 257 ); | |
| 258 this._refreshTurnGroups("start"); | |
| 259 return; | |
| 260 } | |
| 261 let group = null; | |
| 262 for (const turn of visibleTurns) { | |
| 263 if (turn.role === "user" || !group) { | |
| 264 this._turnSequence++; | |
| 265 group = `turn-${this._turnSequence}`; | |
| 266 this._activeGroup = group; | |
| 267 } | |
| 268 this._createMessage( | |
| 92 turn.role === "user" ? "June" : "Epi", | 269 turn.role === "user" ? "June" : "Epi", |
| 93 turn.content || (turn.status === "failed" ? "The quest failed." : ""), | 270 turn.content || |
| 271 (turn.status === "aborted" | |
| 272 ? "Quest cancelled." | |
| 273 : turn.status === "failed" | |
| 274 ? "The quest failed." | |
| 275 : ""), | |
| 276 group, | |
| 277 false, | |
| 94 ); | 278 ); |
| 95 } | 279 } |
| 280 this._refreshTurnGroups(group); | |
| 96 } | 281 } |
| 97 | 282 |
| 98 startAssistantMessage() { | 283 startAssistantMessage() { |
| 99 return this.appendMessage("Epi", ""); | 284 return this.appendMessage("Epi", ""); |
| 100 } | 285 } |
| 101 | 286 |
| 102 setMessageText(item, text) { | 287 setMessageText(item, text) { |
| 103 const copy = item?.querySelector("p"); | 288 const copy = item?.querySelector("p"); |
| 104 if (copy) copy.textContent = text; | 289 if (copy) copy.textContent = text; |
| 290 this.scrollEnd(); | |
| 291 } | |
| 292 | |
| 293 setMessageStreaming(item, streaming) { | |
| 294 if (!item) return; | |
| 295 if (streaming) item.dataset.streaming = "true"; | |
| 296 else delete item.dataset.streaming; | |
| 297 this._viewport?.setAttribute("aria-busy", String(streaming)); | |
| 298 } | |
| 299 | |
| 300 _refreshTurnGroups(preferredGroup) { | |
| 301 const groups = []; | |
| 302 for (const item of this._messages?.children || []) { | |
| 303 const group = item.dataset.turnGroup || "start"; | |
| 304 item.dataset.turnGroup = group; | |
| 305 if (!groups.includes(group)) groups.push(group); | |
| 306 } | |
| 307 this._turnGroups = groups; | |
| 308 const preferredIndex = groups.indexOf(preferredGroup); | |
| 309 const currentIndex = groups.indexOf(this._currentGroup); | |
| 310 this._turnIndex = preferredIndex >= 0 | |
| 311 ? preferredIndex | |
| 312 : currentIndex >= 0 | |
| 313 ? currentIndex | |
| 314 : Math.max(0, groups.length - 1); | |
| 315 this._showTurn(); | |
| 316 } | |
| 317 | |
| 318 _moveTurn(offset) { | |
| 319 const nextIndex = Math.max( | |
| 320 0, | |
| 321 Math.min(this._turnGroups.length - 1, this._turnIndex + offset), | |
| 322 ); | |
| 323 if (nextIndex === this._turnIndex) return; | |
| 324 this._turnIndex = nextIndex; | |
| 325 this._showTurn(); | |
| 326 } | |
| 327 | |
| 328 _showTurn() { | |
| 329 const group = this._turnGroups?.[this._turnIndex]; | |
| 330 this._currentGroup = group; | |
| 331 for (const item of this._messages?.children || []) { | |
| 332 item.hidden = item.dataset.turnGroup !== group; | |
| 333 } | |
| 334 if (this._previousButton) this._previousButton.disabled = this._turnIndex <= 0; | |
| 335 if (this._nextButton) { | |
| 336 this._nextButton.disabled = | |
| 337 this._turnIndex >= (this._turnGroups?.length || 0) - 1; | |
| 338 } | |
| 339 if (this._position) { | |
| 340 if (group === "start") { | |
| 341 this._position.textContent = "START"; | |
| 342 } else { | |
| 343 const turns = this._turnGroups.filter(item => item !== "start"); | |
| 344 this._position.textContent = | |
| 345 `TURN ${turns.indexOf(group) + 1} / ${turns.length}`; | |
| 346 } | |
| 347 } | |
| 105 this.scrollEnd(); | 348 this.scrollEnd(); |
| 106 } | 349 } |
| 107 | 350 |
| 108 scrollEnd() { | 351 scrollEnd() { |
| 109 requestAnimationFrame(() => { | 352 requestAnimationFrame(() => { |
| 159 this._button.setAttribute("aria-busy", String(busy)); | 402 this._button.setAttribute("aria-busy", String(busy)); |
| 160 } | 403 } |
| 161 if (this._cancelControl) this._cancelControl.hidden = !busy; | 404 if (this._cancelControl) this._cancelControl.hidden = !busy; |
| 162 } | 405 } |
| 163 | 406 |
| 407 setCancellable(cancellable) { | |
| 408 if (this._cancelControl) this._cancelControl.hidden = !cancellable; | |
| 409 } | |
| 410 | |
| 164 setDisabled(disabled) { | 411 setDisabled(disabled) { |
| 165 if (this._textarea) this._textarea.disabled = disabled; | 412 if (this._textarea) this._textarea.disabled = disabled; |
| 166 if (this._button) this._button.disabled = disabled; | 413 if (this._button) this._button.disabled = disabled; |
| 167 } | 414 } |
| 168 } | 415 } |
| 188 } | 435 } |
| 189 } | 436 } |
| 190 | 437 |
| 191 class MjjJrpgPreview extends HTMLElement { | 438 class MjjJrpgPreview extends HTMLElement { |
| 192 connectedCallback() { | 439 connectedCallback() { |
| 440 this._onClick = event => { | |
| 441 if ( | |
| 442 event.target.closest("[data-zen-trigger]") && | |
| 443 this.dataset.selection === "resume" | |
| 444 ) { | |
| 445 void this.loadResume(); | |
| 446 } | |
| 447 }; | |
| 448 this.addEventListener("click", this._onClick); | |
| 193 this.show(this.dataset.selection || "resume"); | 449 this.show(this.dataset.selection || "resume"); |
| 450 } | |
| 451 | |
| 452 disconnectedCallback() { | |
| 453 this.removeEventListener("click", this._onClick); | |
| 194 } | 454 } |
| 195 | 455 |
| 196 show(selection) { | 456 show(selection) { |
| 197 const preview = PREVIEWS[selection] || PREVIEWS.resume; | 457 const preview = PREVIEWS[selection] || PREVIEWS.resume; |
| 198 this.dataset.selection = selection; | 458 this.dataset.selection = selection; |
| 200 this.querySelector("[data-preview-title]").textContent = preview.title; | 460 this.querySelector("[data-preview-title]").textContent = preview.title; |
| 201 this.querySelector("[data-preview-copy]").textContent = preview.copy; | 461 this.querySelector("[data-preview-copy]").textContent = preview.copy; |
| 202 this.querySelector("[data-preview-type]").textContent = preview.type; | 462 this.querySelector("[data-preview-type]").textContent = preview.type; |
| 203 this.querySelector("[data-dialog-title]").textContent = preview.title; | 463 this.querySelector("[data-dialog-title]").textContent = preview.title; |
| 204 this.querySelector("[data-dialog-copy]").textContent = preview.copy; | 464 this.querySelector("[data-dialog-copy]").textContent = preview.copy; |
| 465 const showcase = this.querySelector("[data-work-showcase]"); | |
| 466 showcase.replaceChildren(...preview.works.map(work => { | |
| 467 const item = document.createElement("li"); | |
| 468 const link = document.createElement("a"); | |
| 469 const label = document.createElement("span"); | |
| 470 const detail = document.createElement("small"); | |
| 471 link.href = work.url; | |
| 472 if (new URL(work.url, window.location.href).origin !== window.location.origin) { | |
| 473 link.target = "_blank"; | |
| 474 link.rel = "noreferrer"; | |
| 475 } | |
| 476 label.textContent = work.label; | |
| 477 detail.textContent = work.detail; | |
| 478 link.append(label, detail); | |
| 479 item.append(link); | |
| 480 return item; | |
| 481 })); | |
| 205 const link = this.querySelector("[data-preview-link]"); | 482 const link = this.querySelector("[data-preview-link]"); |
| 206 link.href = preview.url; | 483 link.href = preview.url; |
| 207 link.setAttribute("aria-label", `Open ${preview.title}`); | 484 link.setAttribute("aria-label", `Open ${preview.title}`); |
| 485 const resumeDossier = this.querySelector("[data-resume-dossier]"); | |
| 486 const resumeDownload = this.querySelector("[data-resume-download]"); | |
| 487 const isResume = selection === "resume"; | |
| 488 resumeDossier.hidden = !isResume; | |
| 489 resumeDownload.hidden = !isResume; | |
| 490 this.querySelector("[data-dialog-copy]").hidden = isResume; | |
| 491 } | |
| 492 | |
| 493 async loadResume() { | |
| 494 if (this._resumeLoaded || this._resumeLoading) return; | |
| 495 this._resumeLoading = true; | |
| 496 const status = this.querySelector("[data-resume-status]"); | |
| 497 const content = this.querySelector("[data-resume-content]"); | |
| 498 try { | |
| 499 const response = await fetch("/resume", { | |
| 500 headers: { Accept: "text/html" }, | |
| 501 }); | |
| 502 if (!response.ok) { | |
| 503 throw new Error(`Resume request failed (${response.status})`); | |
| 504 } | |
| 505 const documentCopy = new DOMParser().parseFromString( | |
| 506 await response.text(), | |
| 507 "text/html", | |
| 508 ); | |
| 509 const resume = documentCopy.querySelector("main"); | |
| 510 if (!resume) throw new Error("Resume content is unavailable"); | |
| 511 for (const unsafe of resume.querySelectorAll( | |
| 512 "embed, iframe, object, script, style, svg", | |
| 513 )) { | |
| 514 unsafe.remove(); | |
| 515 } | |
| 516 for (const element of resume.querySelectorAll("*")) { | |
| 517 for (const attribute of [...element.attributes]) { | |
| 518 if (attribute.name.toLowerCase().startsWith("on")) { | |
| 519 element.removeAttribute(attribute.name); | |
| 520 } | |
| 521 } | |
| 522 } | |
| 523 for (const anchor of resume.querySelectorAll("a")) { | |
| 524 const target = new URL(anchor.href, window.location.href); | |
| 525 if (target.origin !== window.location.origin) { | |
| 526 anchor.target = "_blank"; | |
| 527 anchor.rel = "noreferrer"; | |
| 528 } | |
| 529 } | |
| 530 content.replaceChildren(...resume.childNodes); | |
| 531 status.hidden = true; | |
| 532 this._resumeLoaded = true; | |
| 533 } catch (error) { | |
| 534 status.textContent = `Unable to load resume: ${error.message}`; | |
| 535 } finally { | |
| 536 this._resumeLoading = false; | |
| 537 } | |
| 208 } | 538 } |
| 209 } | 539 } |
| 210 | 540 |
| 211 class MjjJrpgShell extends HTMLElement {} | 541 class MjjJrpgShell extends HTMLElement {} |
| 212 | 542 |
| 325 chat?.appendMessage("June", event.detail.message); | 655 chat?.appendMessage("June", event.detail.message); |
| 326 composer?.setBusy(true); | 656 composer?.setBusy(true); |
| 327 character?.setAttribute("state", "thinking"); | 657 character?.setAttribute("state", "thinking"); |
| 328 window.clearTimeout(characterTimer); | 658 window.clearTimeout(characterTimer); |
| 329 const assistantItem = chat?.startAssistantMessage(); | 659 const assistantItem = chat?.startAssistantMessage(); |
| 330 let assistantText = ""; | 660 chat?.setMessageStreaming(assistantItem, true); |
| 661 const assistantText = new StreamingTextAnimator(text => { | |
| 662 chat?.setMessageText(assistantItem, text); | |
| 663 }); | |
| 331 let turnFinished = false; | 664 let turnFinished = false; |
| 332 activeController = new AbortController(); | 665 activeController = new AbortController(); |
| 666 activeController.signal.addEventListener("abort", () => { | |
| 667 assistantText.cancel(); | |
| 668 }, { once: true }); | |
| 333 | 669 |
| 334 try { | 670 try { |
| 335 const createConversation = async () => { | 671 const createConversation = async () => { |
| 336 const conversation = await requestJson("/api/conversations", { | 672 const conversation = await requestJson("/api/conversations", { |
| 337 method: "POST", | 673 method: "POST", |
| 368 // Preserve the status-derived message. | 704 // Preserve the status-derived message. |
| 369 } | 705 } |
| 370 throw new Error(message); | 706 throw new Error(message); |
| 371 } | 707 } |
| 372 await consumeSse(response, (eventName, data) => { | 708 await consumeSse(response, (eventName, data) => { |
| 709 shell?.dispatchEvent(new CustomEvent("mjj-jrpg-stream-event", { | |
| 710 bubbles: true, | |
| 711 detail: { | |
| 712 data, | |
| 713 type: eventName, | |
| 714 }, | |
| 715 })); | |
| 373 if (eventName === "assistant.delta") { | 716 if (eventName === "assistant.delta") { |
| 374 assistantText += data.delta || ""; | 717 assistantText.append(data.delta || ""); |
| 375 chat?.setMessageText(assistantItem, assistantText); | |
| 376 } else if (eventName === "assistant.completed") { | 718 } else if (eventName === "assistant.completed") { |
| 377 assistantText = data.content || assistantText; | 719 assistantText.complete(data.content); |
| 378 chat?.setMessageText(assistantItem, assistantText); | |
| 379 } else if (eventName === "turn.error") { | 720 } else if (eventName === "turn.error") { |
| 380 throw new Error(data.message || "Inference turn failed"); | 721 throw new Error( |
| 722 data.error?.message || data.message || "Inference turn failed", | |
| 723 ); | |
| 381 } else if (eventName === "turn.done") { | 724 } else if (eventName === "turn.done") { |
| 382 turnFinished = true; | 725 turnFinished = true; |
| 383 if (data.failed) throw new Error("Inference turn failed"); | 726 composer?.setCancellable(false); |
| 727 if (data.failed || data.aborted) { | |
| 728 throw new Error( | |
| 729 data.aborted ? "Inference turn aborted" : "Inference turn failed", | |
| 730 ); | |
| 731 } | |
| 384 } | 732 } |
| 385 }); | 733 }); |
| 386 if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); | 734 if (!turnFinished) throw new Error("Inference stream ended unexpectedly"); |
| 735 await assistantText.waitForIdle(); | |
| 736 if (activeController.signal.aborted) { | |
| 737 throw new DOMException("Quest cancelled", "AbortError"); | |
| 738 } | |
| 739 chat?.setMessageStreaming(assistantItem, false); | |
| 387 character?.setAttribute("state", "default"); | 740 character?.setAttribute("state", "default"); |
| 388 } catch (error) { | 741 } catch (error) { |
| 389 const aborted = error instanceof DOMException && error.name === "AbortError"; | 742 const aborted = error instanceof DOMException && error.name === "AbortError"; |
| 743 assistantText.cancel(); | |
| 744 chat?.setMessageStreaming(assistantItem, false); | |
| 390 chat?.setMessageText( | 745 chat?.setMessageText( |
| 391 assistantItem, | 746 assistantItem, |
| 392 aborted ? "Quest cancelled." : `System error: ${error.message}`, | 747 aborted ? "Quest cancelled." : `System error: ${error.message}`, |
| 393 ); | 748 ); |
| 394 character?.setAttribute("state", "sad"); | 749 character?.setAttribute("state", "sad"); |