PageSourceSearch

https://tinylytics.app/assets/controllers/insights_chat_controller-1b930279.js

js tinylytics.app collected 2026-10-02 05:42:18 UTC 20,999 bytes, 797 lines download raw bytes

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.