PageSourceSearch

https://www.bernstein.io/assets/controllers/brainstorm_chat_controller-5c85fb55.js

js bernstein.io collected 2026-10-04 08:55:10 UTC 4,212 bytes, 128 lines download raw bytes

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.