PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/courses/tutor_controller-bad5447a.js

js littlebirdelectronics.com.au collected 2026-09-25 14:09:14 UTC 9,345 bytes, 260 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import {
3  parser as smdParser,
4  parser_write as smdWrite,
5  parser_end as smdEnd,
6  default_renderer as smdRenderer
7} from "streaming-markdown"
8
9// Lesson tutor — direct port of `LessonTutor` from book.jsx (line 511-).
10// Posts the student's question + lesson context to
11// `POST /courses/:course_slug/lessons/:lesson_slug/tutor`, which calls
12// `Courses::Ai::TutorReply` (gpt-6-sol) and returns a JSON `{ok, reply}`.
13//
14// The drawer's `data-open` is flipped in lockstep on the FAB / scrim /
15// drawer / topbar `✦` button so all four surfaces tell the same story.
16// Chat history persists per-lesson in localStorage so the conversation
17// survives a refresh.
18export default class extends Controller {
19  static targets = ["fab", "scrim", "drawer", "chat", "topbarBtn",
20                    "input", "send", "suggests"]
21  static values = { courseSlug: String, lessonSlug: String }
22
23  connect() {
24    this.boundKeydown = this.onKeydown.bind(this)
25    document.addEventListener("keydown", this.boundKeydown)
26    this.busy = false
27    this.history = this.loadHistory()
28    this.renderHistory()
29    if (this.hasInputTarget) {
30      this.inputTarget.disabled = false
31      this.inputTarget.addEventListener("keydown", (e) => {
32        if (e.key === "Enter" && !e.shiftKey) {
33          e.preventDefault()
34          this.ask()
35        }
36      })
37      // Auto-grow floor matches `.tutor-input { min-height: 44px }` in
38      // _styles.html.erb (rows="2" usually renders ~50px so the textarea
39      // already starts a little above this); ceiling matches the CSS
40      // `max-height: 140px` cap so the user can scroll inside the box.
41      this.minInputHeight = 44
42      this.maxInputHeight = 140
43    }
44    if (this.hasSendTarget) {
45      this.sendTarget.disabled = false
46      this.sendTarget.addEventListener("click", () => this.ask())
47    }
48    // Once we have a chat history, the suggestion chips are noise — hide
49    // them straight away rather than wait for the first user message.
50    if (this.history.length && this.hasSuggestsTarget) {
51      this.suggestsTarget.hidden = true
52    }
53  }
54
55  disconnect() {
56    document.removeEventListener("keydown", this.boundKeydown)
57  }
58
59  open() { this.setOpen(true) }
60  close() { this.setOpen(false) }
61  toggle() { this.setOpen(!this.isOpen()) }
62
63  setOpen(open) {
64    const val = open ? "true" : "false"
65    if (this.hasFabTarget) this.fabTarget.dataset.open = val
66    if (this.hasScrimTarget) this.scrimTarget.dataset.open = val
67    if (this.hasDrawerTarget) {
68      this.drawerTarget.dataset.open = val
69      this.drawerTarget.setAttribute("aria-hidden", open ? "false" : "true")
70    }
71    if (this.hasTopbarBtnTarget) this.topbarBtnTarget.dataset.on = val
72    if (open && this.hasInputTarget) setTimeout(() => this.inputTarget.focus(), 60)
73  }
74
75  isOpen() {
76    return this.hasDrawerTarget && this.drawerTarget.dataset.open === "true"
77  }
78
79  onKeydown(event) {
80    if (event.key === "Escape" && this.isOpen()) this.close()
81  }
82
83  // ── chat ────────────────────────────────────────────────────────────
84
85  // A chip click — drop the question into the textarea and fire `ask()`
86  // so the user sees their message land in chat the same as a typed one.
87  askSuggestion(event) {
88    const q = event.currentTarget?.dataset?.question
89    if (!q || !this.hasInputTarget) return
90    this.inputTarget.value = q
91    this.autoresize()
92    this.ask()
93  }
94
95  // Auto-grow the textarea up to `maxInputHeight`. Triggered from the
96  // input's `data-action="input->…#autoresize"` plus after chip clicks.
97  autoresize() {
98    if (!this.hasInputTarget) return
99    const el = this.inputTarget
100    el.style.height = "auto"
101    const next = Math.min(this.maxInputHeight, el.scrollHeight)
102    el.style.height = `${Math.max(this.minInputHeight, next)}px`
103  }
104
105  async ask() {
106    if (this.busy || !this.hasInputTarget) return
107    const text = this.inputTarget.value.trim()
108    if (!text) return
109
110    this.busy = true
111    this.inputTarget.value = ""
112    this.autoresize() // collapse back to a single row once sent
113    if (this.hasSendTarget) this.sendTarget.disabled = true
114    // First question — fade the chips out so the chat takes over.
115    if (this.hasSuggestsTarget) this.suggestsTarget.hidden = true
116
117    this.pushMessage({ role: "user", text })
118    const typingEl = this.appendTyping()
119
120    try {
121      const reply = await this.fetchReply(text)
122      typingEl?.remove()
123      if (reply) {
124        this.pushMessage({ role: "ai", text: reply })
125      } else {
126        this.pushMessage({ role: "ai", text: "I hit a snag reaching the tutor. Try again in a moment — your question is in the box above." })
127      }
128    } catch (err) {
129      typingEl?.remove()
130      this.pushMessage({ role: "ai", text: `Network error: ${err?.message || err}` })
131    } finally {
132      this.busy = false
133      if (this.hasSendTarget) this.sendTarget.disabled = false
134      this.inputTarget.focus()
135    }
136  }
137
138  async fetchReply(question) {
139    const url = `/courses/${this.courseSlugValue}/lessons/${this.lessonSlugValue}/tutor`
140    const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ""
141    const res = await fetch(url, {
142      method: "POST",
143      headers: {
144        "Content-Type": "application/json",
145        "Accept": "application/json",
146        "X-CSRF-Token": csrf,
147        "X-Requested-With": "XMLHttpRequest"
148      },
149      credentials: "same-origin",
150      body: JSON.stringify({ question, history: this.history })
151    })
152    let json = null
153    try { json = await res.json() } catch (_) {}
154    if (!res.ok) return json?.reply || null
155    return json?.reply || null
156  }
157
158  // ── render ──────────────────────────────────────────────────────────
159
160  pushMessage(msg) {
161    this.history.push(msg)
162    this.saveHistory()
163    this.appendMessage(msg)
164  }
165
166  renderHistory() {
167    if (!this.hasChatTarget) return
168    this.chatTarget.innerHTML = ""
169    if (!this.history.length) {
170      this.chatTarget.innerHTML = this.emptyStateHtml()
171      return
172    }
173    this.history.forEach((m) => this.appendMessage(m))
174  }
175
176  appendMessage(msg) {
177    if (!this.hasChatTarget) return
178    // Drop the empty state on first message.
179    const empty = this.chatTarget.querySelector(".tutor-empty")
180    if (empty) empty.remove()
181    const wrap = document.createElement("div")
182    wrap.className = `tutor-msg ${msg.role === "user" ? "user" : "ai"}`
183    const meta = document.createElement("div")
184    meta.className = "meta"
185    meta.textContent = msg.role === "user" ? "You" : "Tutor"
186    const bubble = document.createElement("div")
187    bubble.className = "bubble"
188    if (msg.role === "user") {
189      // User input — keep verbatim so backticks etc. aren't reinterpreted.
190      bubble.textContent = msg.text
191    } else {
192      // Assistant — render markdown via thetarnav/streaming-markdown.
193      // `default_renderer` mutates `bubble` directly to insert <p>, <code>,
194      // <strong>, <em>, etc. The parser is one-shot here (the reply lands
195      // whole, not chunked) so we write + end immediately.
196      this.renderMarkdownInto(bubble, msg.text)
197    }
198    wrap.appendChild(meta)
199    wrap.appendChild(bubble)
200    this.chatTarget.appendChild(wrap)
201    this.scrollChatToBottom()
202  }
203
204  renderMarkdownInto(el, text) {
205    try {
206      const renderer = smdRenderer(el)
207      const p = smdParser(renderer)
208      smdWrite(p, text || "")
209      smdEnd(p)
210    } catch (err) {
211      // Fall back to plain-text if the parser throws on weird input.
212      el.textContent = text || ""
213    }
214  }
215
216  appendTyping() {
217    if (!this.hasChatTarget) return null
218    const wrap = document.createElement("div")
219    wrap.className = "tutor-msg ai"
220    wrap.innerHTML = `<div class="meta">Tutor</div><div class="bubble"><span class="tutor-typing"><i></i><i></i><i></i></span></div>`
221    this.chatTarget.appendChild(wrap)
222    this.scrollChatToBottom()
223    return wrap
224  }
225
226  emptyStateHtml() {
227    return `<div class="tutor-empty">
228      <div class="icon" aria-hidden="true">✦</div>
229      <div class="t">Ask anything about this lesson</div>
230      <div class="s">I'll answer using just this lesson's content — try a "why does …" or "what happens if …" question.</div>
231    </div>`
232  }
233
234  scrollChatToBottom() {
235    if (!this.hasChatTarget) return
236    this.chatTarget.scrollTop = this.chatTarget.scrollHeight
237  }
238
239  // ── persistence ─────────────────────────────────────────────────────
240
241  storageKey() {
242    return `courses.tutor:${this.lessonSlugValue || "global"}`
243  }
244
245  loadHistory() {
246    try {
247      const raw = localStorage.getItem(this.storageKey())
248      const parsed = raw ? JSON.parse(raw) : []
249      return Array.isArray(parsed) ? parsed : []
250    } catch (_) { return [] }
251  }
252
253  saveHistory() {
254    try {
255      // Cap stored history so the request payload stays small.
256      const trimmed = this.history.slice(-20)
257      localStorage.setItem(this.storageKey(), JSON.stringify(trimmed))
258    } catch (_) {}
259  }
260}

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.