1import { Controller } from "@hotwired/stimulus" 2 3const THINKING_MESSAGES = [ 4 "Thinkingâ¦", 5 "Looking at your dataâ¦", 6 "Checking the numbersâ¦", 7 "Hang on a secâ¦" 8] 9const THINKING_SIZER = THINKING_MESSAGES.reduce((longest, message) => ( 10 message.length > longest.length ? message : longest 11)) 12 13const ERROR_NOTICE = "That didn't go through. Want to try asking again?" 14const READY_NOTICE = "I've got an answer." 15const openPanels = new Set() 16 17export default class extends Controller { 18 static targets = ["transcript", "panelInput", "sendButton", "status", "panel", "info", "infoButton", "clearButton", "notice", "noticeText"] 19 static values = { 20 url: String, 21 history: Array, 22 responseId: String, 23 storageKey: String, 24 subject: { type: String, default: "your sites" } 25 } 26 27 connect() { 28 this.sending = false 29 this.errorNotice = "" 30 this.pendingWorkingNotice = false 31 this.responseId = this.validResponseId(this.responseIdValue) 32 this.renderHistory(this.history) 33 this.boundBeforeCache = this.prepareForCache.bind(this) 34 this.boundOpenFromHeader = this.openFromHeader.bind(this) 35 this.boundClickOutside = this.clickOutside.bind(this) 36 this.boundEscape = this.closeOnEscape.bind(this) 37 this.boundCloseAnimationEnd = this.onCloseAnimationEnd.bind(this) 38 document.addEventListener("turbo:before-cache", this.boundBeforeCache) 39 window.addEventListener("insights-chat:open", this.boundOpenFromHeader) 40 41 if (this.shouldStayOpen) { 42 this.open({ quiet: true }) 43 } 44 } 45 46 disconnect() { 47 this.hideNotice() 48 this.pauseWork() 49 this.persistState() 50 this.unlistenForDismiss() 51 document.removeEventListener("turbo:before-cache", this.boundBeforeCache) 52 window.removeEventListener("insights-chat:open", this.boundOpenFromHeader) 53 } 54 55 async send(event) { 56 if (event) { 57 event.preventDefault() 58 } 59 60 if (this.sending || !this.hasPanelInputTarget) { 61 return 62 } 63 64 const message = this.panelInputTarget.value.trim() 65 if (!message) { 66 return 67 } 68 69 this.open() 70 this.sending = true 71 this.errorNotice = "" 72 this.abortController = new AbortController() 73 this.updateUiForRequest(true) 74 this.clearInputs() 75 this.startThinking() 76 this.appendTurn(message, "") 77 this.setStatus("") 78 79 try { 80 const response = await fetch(this.urlValue, { 81 method: "POST", 82 headers: { 83 "Content-Type": "application/json", 84 "Accept": "text/event-stream", 85 "X-CSRF-Token": this.csrfToken 86 }, 87 body: JSON.stringify({ 88 message, 89 previous_response_id: this.responseId, 90 segment: new URLSearchParams(window.location.search).get("segment") 91 }), 92 signal: this.abortController.signal 93 }) 94 95 if (!response.ok || !response.body) { 96 this.handleError() 97 return 98 } 99 100 await this.readStream(response.body) 101 } catch (error) { 102 if (error.name === "AbortError") { 103 return 104 } 105 106 this.handleError() 107 } finally { 108 this.sending = false 109 this.abortController = null 110 this.stopThinking() 111 this.updateUiForRequest(false) 112 if (this.isOpen) { 113 this.focusInput() 114 } else if (this.errorNotice) { 115 this.showNotice(this.errorNotice) 116 } else if (this.lastTurnHasAssistant()) { 117 this.showNotice(READY_NOTICE, { ready: true }) 118 } 119 } 120 } 121 122 open(options = {}) { 123 if (!this.hasPanelTarget) { 124 return 125 } 126 127 this.hideNotice() 128 this.cancelCloseAnimation() 129 130 const quiet = options.quiet === true 131 const wasHidden = this.panelTarget.hidden 132 133 if (quiet) { 134 this.panelTarget.classList.add("insights-chat-panel--quiet") 135 } 136 137 if (!this.isOpen) { 138 this.panelTarget.hidden = false 139 if (wasHidden && this.history.length === 0) { 140 this.renderHistory(this.history) 141 } 142 this.scrollTranscriptToBottom() 143 } 144 145 this.rememberOpen(true) 146 this.listenForDismiss() 147 148 if (quiet) { 149 requestAnimationFrame(() => { 150 if (this.hasPanelTarget) { 151 this.panelTarget.classList.remove("insights-chat-panel--quiet") 152 } 153 }) 154 } 155 } 156 157 openFromHeader() { 158 if (this.closing || this.isOpen) { 159 this.close() 160 return 161 } 162 163 this.open() 164 this.focusInput() 165 } 166 167 openFromNotice(event) { 168 event?.preventDefault() 169 event?.stopPropagation() 170 this.hideNotice() 171 this.open() 172 this.focusInput() 173 } 174 175 dismissNotice(event) { 176 event?.preventDefault() 177 event?.stopPropagation() 178 this.hideNotice() 179 } 180 181 clearHistory(event) { 182 event?.preventDefault() 183 event?.stopPropagation() 184 if (this.history.length === 0 && !this.sending) { 185 return 186 } 187 188 if (!window.confirm("Clear this chat? I won't remember what we talked about today.")) { 189 return 190 } 191 192 this.closeInfo() 193 this.hideNotice() 194 this.abortController?.abort() 195 this.abortController = null 196 this.sending = false 197 this.stopThinking() 198 this.updateUiForRequest(false) 199 this.history = [] 200 this.responseId = null 201 this.errorNotice = "" 202 this.persistState() 203 this.renderHistory(this.history) 204 this.focusInput() 205 206 fetch(this.urlValue, { 207 method: "DELETE", 208 headers: { 209 "X-CSRF-Token": this.csrfToken, 210 "Accept": "application/json" 211 } 212 }) 213 } 214 215 close(event) { 216 if (event?.type === "click" && event.detail > 0) { 217 return 218 } 219 220 event?.preventDefault() 221 this.dismissPanel() 222 } 223 224 prepareForCache() { 225 this.pauseWork() 226 this.closeInfo() 227 this.persistState() 228 } 229 230 pauseWork() { 231 this.abortController?.abort() 232 this.abortController = null 233 this.stopThinking() 234 } 235 236 dismissPanel(immediate = false) {
237 const keepWorking = this.sending 238 this.closeInfo() 239 this.unlistenForDismiss() 240 241 if (keepWorking) { 242 this.pendingWorkingNotice = true 243 } 244 245 if (!this.hasPanelTarget || this.panelTarget.hidden) { 246 this.cancelCloseAnimation() 247 this.revealWorkingNotice() 248 return 249 } 250 251 if (immediate || window.matchMedia("(prefers-reduced-motion: reduce)").matches) { 252 this.finishClose() 253 return 254 } 255 256 if (this.closing) { 257 return 258 } 259 260 this.closing = true 261 this.panelTarget.classList.add("insights-chat-panel--closing") 262 this.panelTarget.addEventListener("animationend", this.boundCloseAnimationEnd) 263 this.closeTimer = setTimeout(() => this.finishClose(), 250) 264 } 265 266 onCloseAnimationEnd(event) { 267 if (event.target !== this.panelTarget || event.animationName !== "insights-chat-slide-down") { 268 return 269 } 270 271 this.finishClose() 272 } 273 274 finishClose() { 275 this.cancelCloseAnimation() 276 this.rememberOpen(false) 277 278 if (this.hasPanelTarget) { 279 this.panelTarget.hidden = true 280 } 281 282 this.revealWorkingNotice() 283 } 284 285 cancelCloseAnimation() { 286 this.closing = false 287 288 if (this.closeTimer) { 289 clearTimeout(this.closeTimer) 290 this.closeTimer = null 291 } 292 293 if (this.hasPanelTarget) { 294 this.panelTarget.classList.remove("insights-chat-panel--closing") 295 this.panelTarget.removeEventListener("animationend", this.boundCloseAnimationEnd) 296 } 297 } 298 299 closeOnEscape(event) { 300 if (event.key !== "Escape" || !this.isOpen) { 301 return 302 } 303 304 if (this.hasInfoTarget && !this.infoTarget.hidden) { 305 this.closeInfo() 306 return 307 } 308 309 this.close() 310 } 311 312 clickOutside(event) { 313 if (!this.isOpen) { 314 return 315 } 316 317 if (this.hasInfoTarget && !this.infoTarget.hidden) { 318 const onInfo = this.infoTarget.contains(event.target) || event.target.closest("[data-insights-chat-target='infoButton']") 319 if (!onInfo) { 320 this.closeInfo() 321 } 322 } 323 324 if (this.panelTarget.contains(event.target)) { 325 return 326 } 327 328 if (event.target.closest("[data-controller~='insights-chat-open']")) { 329 return 330 } 331 332 this.close() 333 } 334 335 toggleInfo(event) { 336 event.stopPropagation() 337 if (!this.hasInfoTarget) { 338 return 339 } 340 341 this.infoTarget.hidden = !this.infoTarget.hidden 342 if (this.hasInfoButtonTarget) { 343 this.infoButtonTarget.setAttribute("aria-expanded", String(!this.infoTarget.hidden)) 344 } 345 } 346 347 closeInfo() { 348 if (this.hasInfoTarget) { 349 this.infoTarget.hidden = true 350 } 351 352 if (this.hasInfoButtonTarget) { 353 this.infoButtonTarget.setAttribute("aria-expanded", "false") 354 } 355 } 356 357 listenForDismiss() { 358 this.unlistenForDismiss() 359 document.addEventListener("pointerdown", this.boundClickOutside, true) 360 document.addEventListener("keydown", this.boundEscape) 361 } 362 363 unlistenForDismiss() { 364 document.removeEventListener("pointerdown", this.boundClickOutside, true) 365 document.removeEventListener("keydown", this.boundEscape) 366 } 367 368 async readStream(body) { 369 const reader = body.getReader() 370 const decoder = new TextDecoder() 371 let buffer = "" 372 373 while (true) { 374 const { done, value } = await reader.read() 375 if (done) { 376 break 377 } 378 379 buffer += decoder.decode(value, { stream: true }) 380 const frames = buffer.split("\n\n") 381 buffer = frames.pop() || "" 382 383 frames.forEach((frame) => this.handleFrame(frame)) 384 } 385 } 386 387 handleFrame(frame) { 388 const payload = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("") 389 if (!payload) { 390 return 391 } 392 393 const event = JSON.parse(payload) 394 395 if (event.type === "status") { 396 return 397 } else if (event.type === "delta") { 398 this.appendAssistant(event.text || "", event.html) 399 this.stopThinking() 400 this.setStatus("") 401 } else if (event.type === "done") { 402 this.responseId = this.validResponseId(event.response_id) || this.responseId 403 this.persistState() 404 } else if (event.type === "error") { 405 this.handleError(event.text) 406 } 407 } 408 409 appendTurn(user, assistant) { 410 this.history = [ ...this.history, { user, assistant, assistantHtml: "" } ] 411 this.renderHistory(this.history) 412 } 413 414 appendAssistant(text, html) { 415 const history = this.history 416 if (history.length === 0) { 417 return 418 } 419 420 const current = history[history.length - 1] 421 history[history.length - 1] = { 422 ...current, 423 assistant: `${current.assistant || ""}${text}`, 424 assistantHtml: html || current.assistantHtml || "" 425 } 426 this.history = history 427 this.renderHistory(this.history) 428 } 429 430 renderHistory(history) { 431 this.syncClearButton(history) 432 433 if (!this.hasTranscriptTarget) { 434 return 435 } 436 437 if (!Array.isArray(history) || history.length === 0) { 438 this.transcriptTarget.innerHTML = this.errorNotice ? this.errorNoticeHtml() : this.emptyTranscriptHtml() 439 return 440 } 441 442 this.transcriptTarget.innerHTML = history.map((entry) => ` 443 <div class="space-y-2"> 444 <div class="flex justify-end"> 445 <div class="max-w-[90%] rounded-2xl bg-indigo-600 px-3.5 py-2 text-sm text-white">${this.escapeHtml(entry.user || "")}</div> 446 </div> 447 ${entry.assistant || entry.assistantHtml ? `<div class="flex justify-start"> 448 <div class="insights-chat-answer max-w-[95%] rounded-2xl border border-slate-200/80 bg-slate-50 px-3.5 py-3 text-sm leading-6 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200">${entry.assistantHtml || this.escapeHtml(entry.assistant || "")}</div> 449 </div>` : `<div class="flex justify-start"> 450 <div data-insights-chat-thinking class="insights-chat-thinking inline-flex items-center gap-2.5 rounded-2xl border border-dashed border-slate-200 px-3.5 py-3 text-sm text-slate-400 dark:border-slate-600 dar
450k:text-slate-400"> 451 <span class="insights-chat-thinking__dots" aria-hidden="true"> 452 <span></span><span></span><span></span> 453 </span> 454 ${this.rolodexHtml(this.thinkingMessage)} 455 </div> 456 </div>`} 457 </div> 458 `).join("") + (this.errorNotice ? this.errorNoticeHtml() : "") 459 this.scrollTranscriptToBottom() 460 } 461 462 emptyTranscriptHtml() { 463 return ` 464 <div class="flex justify-start"> 465 <p class="insights-chat-greeting">${this.escapeHtml(this.greeting)}</p> 466 </div> 467 ` 468 } 469 470 syncClearButton(history = this.history) { 471 if (!this.hasClearButtonTarget) { 472 return 473 } 474 475 this.clearButtonTarget.hidden = !Array.isArray(history) || history.length === 0 476 } 477 478 errorNoticeHtml() { 479 return ` 480 <div class="flex justify-start"> 481 <p class="insights-chat-error">${this.escapeHtml(this.errorNotice)}</p> 482 </div> 483 ` 484 } 485 486 handleError(text) { 487 this.stopThinking() 488 this.responseId = null 489 const failed = this.popIncompleteTurn() 490 if (failed && this.hasPanelInputTarget) { 491 this.panelInputTarget.value = failed.user 492 } 493 494 this.errorNotice = text || ERROR_NOTICE 495 this.setStatus("") 496 this.renderHistory(this.history) 497 this.persistState() 498 } 499 500 popIncompleteTurn() { 501 const history = this.history 502 const last = history[history.length - 1] 503 if (!last || last.assistant || last.assistantHtml) { 504 return null 505 } 506 507 this.history = history.slice(0, -1) 508 return last 509 } 510 511 lastTurnHasAssistant() { 512 const last = this.history[this.history.length - 1] 513 return Boolean(last && (last.assistant || last.assistantHtml)) 514 } 515 516 revealWorkingNotice() { 517 if (!this.pendingWorkingNotice || !this.sending) { 518 return 519 } 520 521 this.pendingWorkingNotice = false 522 this.showNotice(this.thinkingMessage, { thinking: true }) 523 } 524 525 showNotice(text, { ready = false, thinking = false } = {}) { 526 if (!this.hasNoticeTarget) { 527 return 528 } 529 530 this.noticeTarget.classList.toggle("insights-chat-notice--ready", ready) 531 this.noticeTarget.classList.toggle("insights-chat-notice--thinking", thinking && !ready) 532 this.setNoticeText(text, { 533 roll: thinking && !this.noticeTarget.hidden && !ready, 534 lockWidth: thinking && !ready 535 }) 536 this.noticeTarget.hidden = false 537 } 538 539 setNoticeText(text, { roll = false, lockWidth = true } = {}) { 540 if (!this.hasNoticeTarget) { 541 return 542 } 543 544 let rolodex = this.noticeTarget.querySelector("[data-insights-chat-rolodex]") 545 if (!rolodex && this.hasNoticeTextTarget) { 546 this.noticeTextTarget.innerHTML = this.rolodexHtml(text) 547 rolodex = this.noticeTarget.querySelector("[data-insights-chat-rolodex]") 548 } 549 550 if (roll) { 551 this.rollRolodex(rolodex, text) 552 } else { 553 this.snapRolodex(rolodex, text, { lockWidth }) 554 } 555 } 556 557 hideNotice() { 558 this.pendingWorkingNotice = false 559 if (this.hasNoticeTarget) { 560 this.noticeTarget.hidden = true 561 this.noticeTarget.classList.remove("insights-chat-notice--ready", "insights-chat-notice--thinking") 562 } 563 } 564 565 updateUiForRequest(isLoading) { 566 this.sendButtonTargets.forEach((button) => { 567 button.disabled = isLoading 568 button.classList.toggle("opacity-60", isLoading) 569 }) 570 571 if (this.hasPanelInputTarget) { 572 this.panelInputTarget.disabled = isLoading 573 } 574 } 575 576 clearInputs() { 577 if (this.hasPanelInputTarget) { 578 this.panelInputTarget.value = "" 579 } 580 } 581 582 startThinking() { 583 this.stopThinking() 584 this.thinkingIndex = Math.floor(Math.random() * THINKING_MESSAGES.length) 585 this.toggleThinkingGlow(true) 586 587 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { 588 return 589 } 590 591 this.thinkingTimer = setInterval(() => { 592 this.thinkingIndex = (this.thinkingIndex + 1) % THINKING_MESSAGES.length 593 this.updateThinkingMessage() 594 }, 2000) 595 } 596 597 stopThinking() { 598 if (this.thinkingTimer) { 599 clearInterval(this.thinkingTimer) 600 this.thinkingTimer = null 601 } 602 603 this.toggleThinkingGlow(false) 604 } 605 606 toggleThinkingGlow(on) { 607 if (this.hasPanelTarget) { 608 this.panelTarget.classList.toggle("insights-chat-panel--thinking", on) 609 } 610 611 document.querySelectorAll("[data-controller~='insights-chat-open']").forEach((button) => { 612 button.classList.toggle("insights-chat-open--thinking", on) 613 button.setAttribute("aria-busy", String(on)) 614 }) 615 window.dispatchEvent(new CustomEvent("insights-chat:thinking", { detail: { thinking: on } })) 616 } 617 618 updateThinkingMessage() { 619 if (this.hasNoticeTarget && !this.noticeTarget.hidden) { 620 this.rollRolodex(this.noticeTarget.querySelector("[data-insights-chat-rolodex]"), this.thinkingMessage) 621 } 622 623 if (this.hasTranscriptTarget) { 624 this.rollRolodex(this.transcriptTarget.querySelector("[data-insights-chat-rolodex]"), this.thinkingMessage) 625 } 626 } 627 628 rolodexHtml(text) { 629 return `<span class="insights-chat-rolodex" data-insights-chat-rolodex>
630 <span class="insights-chat-rolodex__sizer" aria-hidden="true">${this.escapeHtml(THINKING_SIZER)}</span> 631 <span class="insights-chat-rolodex__window"> 632 <span class="insights-chat-rolodex__line insights-chat-rolodex__current">${this.escapeHtml(text)}</span> 633 </span> 634 </span>` 635 } 636 637 snapRolodex(root, text, { lockWidth = true } = {}) { 638 if (!root) { 639 return 640 } 641 642 // ponytail: hide (display:none) cancels a mid-roll without animationend, so rebuild 643 root.classList.remove("is-rolling") 644 const sizerText = lockWidth ? THINKING_SIZER : text 645 root.innerHTML = ` 646 <span class="insights-chat-rolodex__sizer" aria-hidden="true">${this.escapeHtml(sizerText)}</span> 647 <span class="insights-chat-rolodex__window"> 648 <span class="insights-chat-rolodex__line insights-chat-rolodex__current">${this.escapeHtml(text)}</span> 649 </span> 650 ` 651 } 652 653 rollRolodex(root, text) { 654 if (!root) { 655 return 656 } 657 658 const current = root.querySelector(".insights-chat-rolodex__current") 659 if (!current || current.textContent === text || root.classList.contains("is-rolling")) { 660 return 661 } 662 663 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { 664 this.snapRolodex(root, text) 665 return 666 } 667 668 const windowEl = root.querySelector(".insights-chat-rolodex__window") 669 if (!windowEl) { 670 this.snapRolodex(root, text) 671 return 672 } 673 674 const incoming = document.createElement("span") 675 incoming.className = "insights-chat-rolodex__line insights-chat-rolodex__incoming" 676 incoming.textContent = text 677 current.classList.add("insights-chat-rolodex__outgoing") 678 windowEl.append(incoming) 679 root.classList.add("is-rolling") 680 681 current.addEventListener("animationend", () => { 682 if (!incoming.isConnected) { 683 return 684 } 685 686 current.remove() 687 incoming.classList.remove("insights-chat-rolodex__incoming") 688 incoming.classList.add("insights-chat-rolodex__current") 689 root.classList.remove("is-rolling") 690 }, { once: true }) 691 } 692 693 setStatus(message) { 694 if (!this.hasStatusTarget) { 695 return 696 } 697 698 this.statusTarget.textContent = message 699 this.statusTarget.hidden = !message 700 } 701 702 scrollTranscriptToBottom() { 703 if (!this.hasTranscriptTarget) { 704 return 705 } 706 707 const el = this.transcriptTarget 708 el.scrollTop = el.scrollHeight 709 requestAnimationFrame(() => { 710 el.scrollTop = el.scrollHeight 711 }) 712 } 713 714 focusInput() { 715 if (!this.hasPanelInputTarget) { 716 return 717 } 718 719 requestAnimationFrame(() => this.panelInputTarget.focus()) 720 } 721 722 escapeHtml(value) { 723 const div = document.createElement("div") 724 div.textContent = value 725 return div.innerHTML 726 } 727 728 persistState() { 729 this.historyValue = this.history 730 this.responseIdValue = this.responseId || "" 731 } 732 733 rememberOpen(open) { 734 if (!this.storageKeyValue) { 735 return 736 } 737 738 if (open) { 739 openPanels.add(this.storageKeyValue) 740 } else { 741 openPanels.delete(this.storageKeyValue) 742 } 743 } 744 745 get shouldStayOpen() { 746 return this.isOpen || openPanels.has(this.storageKeyValue) 747 } 748 749 get isOpen() { 750 return this.hasPanelTarget && !this.panelTarget.hidden 751 } 752 753 get thinkingMessage() { 754 return THINKING_MESSAGES[this.thinkingIndex] || THINKING_MESSAGES[0] 755 } 756 757 get greeting() { 758 if (!this._greeting) { 759 const greetings = [ 760 `Hey. What do you want to ask about ${this.subjectValue}?`, 761 "What should we look at?", 762 "I'm here if you want to poke at the numbers.", 763 "Traffic, referrers, a quiet page â ask away.", 764 "Hi. How are things looking?" 765 ] 766 this._greeting = greetings[Math.floor(Math.random() * greetings.length)] 767 } 768 769 return this._greeting 770 } 771 772 get csrfToken() { 773 return document.querySelector("meta[name='csrf-token']")?.content || "" 774 } 775 776 validResponseId(value) { 777 const id = value && String(value) 778 if (id && id.startsWith("resp_")) { 779 return id 780 } 781 782 return null 783 } 784 785 get history() { 786 if (this._history) { 787 return this._history 788 } 789 790 this._history = Array.isArray(this.historyValue) ? this.historyValue : [] 791 return this._history 792 } 793 794 set history(value) { 795 this._history = Array.isArray(value) ? value : [] 796 } 797}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.