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.