1import { Controller } from "@hotwired/stimulus" 2 3// Backoffice brainstorm chat: posts the operator's message, appends the assistant reply with an 4// "Accept into corpus" action. 5// 6// The assistant's reply is rendered as markdown, because it answers in headings and lists and reading 7// that as literal asterisks is miserable. The HTML is built and sanitized ON THE SERVER 8// (BrainMarkdownHelper) and arrives as `content_html`: the browser inserts markup we produced, never 9// markup the model produced. Raw model text â anything without a server-rendered counterpart â still 10// goes in with textContent. 11export default class extends Controller { 12 static targets = ["messages", "input", "send"] 13 static values = { messageUrl: String } 14 15 connect() { 16 this.pending = false 17 this.scrollToBottom() 18 } 19 20 submitOnEnter(event) { 21 if (event.shiftKey) return 22 event.preventDefault() 23 this.submit(event) 24 } 25 26 async submit(event) { 27 event.preventDefault() 28 const message = this.inputTarget.value.trim() 29 if (!message || this.pending) return 30 31 this.appendBubble("user", message) 32 this.inputTarget.value = "" 33 this.setPending(true) 34 const typing = this.appendTyping() 35 36 try { 37 const response = await fetch(this.messageUrlValue, { 38 method: "POST", 39 headers: { 40 "Content-Type": "application/json", 41 Accept: "application/json", 42 "X-CSRF-Token": this.csrfToken(), 43 }, 44 body: JSON.stringify({ message }), 45 }) 46 typing.remove() 47 48 if (response.ok || response.status === 201) { 49 const payload = await response.json() 50 const bubble = this.appendBubble("assistant", payload.reply.content, payload.reply.content_html) 51 bubble.appendChild(this.buildAcceptForm(payload.reply.accept_note_path)) 52 } else { 53 this.appendBubble("assistant", "The assistant is unavailable right now â try again shortly.") 54 } 55 } catch { 56 typing.remove() 57 this.appendBubble("assistant", "The assistant is unavailable right now â try again shortly.") 58 } finally { 59 this.setPending(false) 60 } 61 } 62 63 // --- helpers ------------------------------------------------------------------------------- 64 65 // `html` is the server's sanitized markdown rendering, when there is one. Without it we fall back to 66 // textContent â an assistant reply is never inserted as raw markup. 67 appendBubble(role, content, html = null) { 68 const bubble = document.createElement("div") 69 bubble.className = `chat-widget-message chat-widget-message-${role}` 70 71 if (html) { 72 const body = document.createElement("div") 73 body.className = "brain-markdown" 74 body.innerHTML = html 75 bubble.appendChild(body) 76 } else { 77 bubble.textContent = content 78 } 79 80 this.messagesTarget.appendChild(bubble) 81 this.scrollToBottom() 82 return bubble 83 } 84 85 appendTyping() { 86 const indicator = document.createElement("div") 87 indicator.className = "chat-widget-message chat-widget-message-assistant chat-widget-typing" 88 for (let dot = 0; dot < 3; dot += 1) indicator.appendChild(document.createElement("span")) 89 this.messagesTarget.appendChild(indicator) 90 this.scrollToBottom() 91 return indicator 92 } 93 94 // A real POST form (Rails-style) so accepting a note works without extra JS state. 95 buildAcceptForm(actionPath) { 96 const form = document.createElement("form") 97 form.method = "post" 98 form.action = actionPath 99 form.className = "brainstorm-accept" 100 101 const token = document.createElement("input") 102 token.type = "hidden" 103 token.name = "authenticity_token" 104 token.value = this.csrfToken() 105 form.appendChild(token) 106 107 const button = document.createElement("button") 108 button.type = "submit" 109 button.className = "btn btn-sm btn-outline-primary" 110 button.textContent = "Accept into corpus" 111 form.appendChild(button) 112 113 return form 114 } 115 116 setPending(pending) { 117 this.pending = pending 118 this.sendTarget.disabled = pending 119 } 120 121 scrollToBottom() { 122 this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight 123 } 124 125 csrfToken() { 126 return document.querySelector('meta[name="csrf-token"]')?.content || "" 127 } 128}
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.