llm_meta_client 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/lib/generators/llm_meta_client/scaffold/scaffold_generator.rb +12 -7
  4. data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/api/mcp_servers_controller.rb +2 -2
  5. data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/chat_streams_controller.rb +24 -2
  6. data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/chats_controller.rb +92 -76
  7. data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/prompts_controller.rb +28 -1
  8. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/asset_actions_controller.js +98 -0
  9. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_controller.js +126 -0
  10. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_menu_controller.js +42 -0
  11. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_title_edit_controller.js +5 -0
  12. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chats_form_controller.js +186 -12
  13. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/generation_settings_controller.js +38 -20
  14. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/input_controls_controller.js +55 -0
  15. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/llm_toggle_controller.js +27 -0
  16. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/message_stream_controller.js +102 -3
  17. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/model_picker_controller.js +160 -0
  18. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/tool_selector_controller.js +10 -2
  19. data/lib/generators/llm_meta_client/scaffold/templates/app/models/chat.rb +130 -44
  20. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_chat_sidebar.html.erb +3 -1
  21. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_message.html.erb +3 -1
  22. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_streaming_message.html.erb +6 -0
  23. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_tool_call_message.html.erb +20 -18
  24. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/create.turbo_stream.erb +31 -0
  25. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/destroy.turbo_stream.erb +3 -0
  26. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/edit.html.erb +53 -17
  27. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/new.html.erb +50 -17
  28. data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_header.html.erb +1 -5
  29. data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_new_chat_button.html.erb +7 -0
  30. data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/application.html.erb +2 -2
  31. data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_generation_settings_field.html.erb +7 -5
  32. data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_model_grid.html.erb +88 -0
  33. data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_quick_picks.html.erb +67 -0
  34. data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_tool_selector_field.html.erb +1 -1
  35. data/lib/llm_meta_client/helpers.rb +18 -0
  36. data/lib/llm_meta_client/server_query.rb +24 -6
  37. data/lib/llm_meta_client/version.rb +1 -1
  38. metadata +11 -6
  39. data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/llm_selector_controller.js +0 -236
  40. data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/update.turbo_stream.erb +0 -85
  41. data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_api_key_field.html.erb +0 -15
  42. data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_family_field.html.erb +0 -18
  43. data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_model_field.html.erb +0 -12
@@ -0,0 +1,126 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["cards", "checkbox", "selectAll", "heading", "bar", "selectedCount"]
5
+ static values = {
6
+ batchDeletePath: String,
7
+ batchDownloadCsvPath: String
8
+ }
9
+
10
+ connect() {
11
+ this.lastIndex = null
12
+ this.#refresh()
13
+ this.#scrollActiveIntoView()
14
+ }
15
+
16
+ // When the sidebar re-renders on chat navigation, the active card may
17
+ // be far below the visible scroll viewport. Center it vertically so
18
+ // the user can see surrounding chats too — `block: "nearest"` would
19
+ // park it at the bottom edge, which makes orientation harder. The
20
+ // browser clamps to the scrollable extent, so cards near the top
21
+ // simply land at the top instead of being forced down. "instant"
22
+ // avoids a jarring scroll animation on every page load.
23
+ #scrollActiveIntoView() {
24
+ const active = this.element.querySelector(".chat-card.is-active")
25
+ if (!active) return
26
+ active.scrollIntoView({ block: "center", behavior: "instant" })
27
+ }
28
+
29
+ toggle(event) {
30
+ const cb = event.target
31
+ const idx = this.checkboxTargets.indexOf(cb)
32
+
33
+ if (event.shiftKey && this.lastIndex !== null && this.lastIndex !== idx) {
34
+ const [a, b] = [this.lastIndex, idx].sort((x, y) => x - y)
35
+ for (let i = a; i <= b; i++) {
36
+ this.checkboxTargets[i].checked = cb.checked
37
+ }
38
+ }
39
+
40
+ this.lastIndex = idx
41
+ this.#refresh()
42
+ }
43
+
44
+ toggleAll(event) {
45
+ const checked = event.target.checked
46
+ this.checkboxTargets.forEach((cb) => { cb.checked = checked })
47
+ this.lastIndex = null
48
+ this.#refresh()
49
+ }
50
+
51
+ bulkDelete(event) {
52
+ event.preventDefault()
53
+ const uuids = this.#selectedUuids()
54
+ if (uuids.length === 0) return
55
+ if (!confirm(`Delete ${uuids.length} chat${uuids.length === 1 ? "" : "s"}? This cannot be undone.`)) return
56
+
57
+ this.#submitForm(this.batchDeletePathValue, "delete", uuids)
58
+ }
59
+
60
+ bulkDownload(event) {
61
+ event.preventDefault()
62
+ const uuids = this.#selectedUuids()
63
+ if (uuids.length === 0) return
64
+
65
+ this.#submitForm(this.batchDownloadCsvPathValue, "post", uuids, { turbo: false })
66
+ }
67
+
68
+ #submitForm(action, method, uuids, opts = {}) {
69
+ const form = document.createElement("form")
70
+ form.method = "POST"
71
+ form.action = action
72
+ form.style.display = "none"
73
+ if (opts.turbo === false) form.setAttribute("data-turbo", "false")
74
+
75
+ if (method !== "post") {
76
+ const m = document.createElement("input")
77
+ m.type = "hidden"; m.name = "_method"; m.value = method
78
+ form.appendChild(m)
79
+ }
80
+
81
+ const csrf = document.querySelector("meta[name=csrf-token]")?.content
82
+ if (csrf) {
83
+ const t = document.createElement("input")
84
+ t.type = "hidden"; t.name = "authenticity_token"; t.value = csrf
85
+ form.appendChild(t)
86
+ }
87
+
88
+ uuids.forEach((uuid) => {
89
+ const i = document.createElement("input")
90
+ i.type = "hidden"; i.name = "uuids[]"; i.value = uuid
91
+ form.appendChild(i)
92
+ })
93
+
94
+ document.body.appendChild(form)
95
+ form.requestSubmit ? form.requestSubmit() : form.submit()
96
+ }
97
+
98
+ #selectedUuids() {
99
+ return this.checkboxTargets.filter((cb) => cb.checked).map((cb) => cb.dataset.uuid)
100
+ }
101
+
102
+ #refresh() {
103
+ const total = this.checkboxTargets.length
104
+ const selected = this.checkboxTargets.filter((cb) => cb.checked)
105
+ const count = selected.length
106
+
107
+ selected.forEach((cb) => cb.closest(".chat-card")?.classList.add("is-selected"))
108
+ this.checkboxTargets.filter((cb) => !cb.checked).forEach((cb) =>
109
+ cb.closest(".chat-card")?.classList.remove("is-selected")
110
+ )
111
+
112
+ if (count > 0) {
113
+ this.headingTarget.classList.add("hidden")
114
+ this.barTarget.classList.remove("hidden")
115
+ if (this.hasSelectedCountTarget) this.selectedCountTarget.textContent = count
116
+ } else {
117
+ this.headingTarget.classList.remove("hidden")
118
+ this.barTarget.classList.add("hidden")
119
+ }
120
+
121
+ if (this.hasSelectAllTarget) {
122
+ this.selectAllTarget.checked = count === total && total > 0
123
+ this.selectAllTarget.indeterminate = count > 0 && count < total
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,42 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu"]
5
+
6
+ toggle(event) {
7
+ event.preventDefault()
8
+ event.stopPropagation()
9
+ const willOpen = !this.menuTarget.classList.contains("open")
10
+ document.querySelectorAll(".chat-card-menu.open").forEach((m) => m.classList.remove("open"))
11
+ if (willOpen) {
12
+ this.menuTarget.classList.add("open")
13
+ document.addEventListener("click", this._onDocClick, { capture: true })
14
+ document.addEventListener("keydown", this._onKeydown)
15
+ }
16
+ }
17
+
18
+ _onDocClick = (event) => {
19
+ if (!this.element.contains(event.target)) {
20
+ this._close()
21
+ }
22
+ }
23
+
24
+ _onKeydown = (event) => {
25
+ if (event.key === "Escape") this._close()
26
+ }
27
+
28
+ close() {
29
+ this._close()
30
+ }
31
+
32
+ _close() {
33
+ this.menuTarget.classList.remove("open")
34
+ document.removeEventListener("click", this._onDocClick, { capture: true })
35
+ document.removeEventListener("keydown", this._onKeydown)
36
+ }
37
+
38
+ disconnect() {
39
+ document.removeEventListener("click", this._onDocClick, { capture: true })
40
+ document.removeEventListener("keydown", this._onKeydown)
41
+ }
42
+ }
@@ -26,6 +26,11 @@ export default class extends Controller {
26
26
  this._startEditing()
27
27
  }
28
28
 
29
+ start() {
30
+ if (this._isEditing) return
31
+ this._startEditing()
32
+ }
33
+
29
34
  _startEditing() {
30
35
  this._isEditing = true
31
36
  this._originalTitle = this.fullTitleValue
@@ -2,14 +2,167 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="chats-form"
4
4
  export default class extends Controller {
5
- static targets = ["text", "prompt", "submit"]
5
+ static targets = [
6
+ "text", "prompt", "submit",
7
+ "imageInput", "imagePreview", "imageThumbnail", "attachButton"
8
+ ]
6
9
 
7
10
  connect() {
8
11
  this.updateSubmitButton()
12
+ this.updateAttachButton()
13
+ this.#restoreStashedPrompt()
14
+ }
15
+
16
+ // Pulls any prompt that was stashed by reauth_banner_controller before
17
+ // bouncing through Google sign-in. One-shot: cleared after restore so
18
+ // a follow-up sign-in doesn't keep re-hydrating stale text.
19
+ #restoreStashedPrompt() {
20
+ if (!this.hasPromptTarget) return
21
+ try {
22
+ const raw = localStorage.getItem("llmMetaPromptStash:v1")
23
+ if (!raw) return
24
+ const stash = JSON.parse(raw)
25
+ // Ignore stashes older than 10 minutes — beyond that the user
26
+ // likely abandoned the flow.
27
+ const STALE_MS = 10 * 60 * 1000
28
+ if (!stash || !stash.prompt || (stash.savedAt && Date.now() - stash.savedAt > STALE_MS)) {
29
+ localStorage.removeItem("llmMetaPromptStash:v1")
30
+ return
31
+ }
32
+ // Don't trample whatever the user has already typed on this page.
33
+ if (this.promptTarget.value && this.promptTarget.value.trim().length > 0) return
34
+ this.promptTarget.value = stash.prompt
35
+ this.updateSubmitButton()
36
+ } catch { /* malformed stash — ignore */ }
37
+ finally {
38
+ localStorage.removeItem("llmMetaPromptStash:v1")
39
+ }
9
40
  }
10
41
 
11
42
  updateSubmitButton() {
12
43
  this.submitTarget.disabled = !this.#canSubmit()
44
+ this.updateAttachButton()
45
+ }
46
+
47
+ // Disable the attach button when the selected model isn't vision-capable.
48
+ // model-picker stamps data-supports-vision on the hidden #model input
49
+ // whenever the user picks a model.
50
+ updateAttachButton() {
51
+ if (!this.hasAttachButtonTarget) return
52
+ const supports = this.#selectedModelSupportsVision()
53
+ this.attachButtonTarget.disabled = !supports
54
+ this.attachButtonTarget.title = supports ? "Attach image" : "Selected model doesn't support images"
55
+ if (!supports && this.hasImageInputTarget && this.imageInputTarget.value) {
56
+ // Clear any previously attached image when switching to a text-only model.
57
+ this.clearImage()
58
+ }
59
+ }
60
+
61
+ openImagePicker() {
62
+ if (!this.hasImageInputTarget) return
63
+ if (this.hasAttachButtonTarget && this.attachButtonTarget.disabled) return
64
+ this.imageInputTarget.click()
65
+ }
66
+
67
+ onImageSelected() {
68
+ if (!this.hasImageInputTarget) return
69
+ const file = this.imageInputTarget.files && this.imageInputTarget.files[0]
70
+ if (!file) return
71
+ if (!this.hasImageThumbnailTarget || !this.hasImagePreviewTarget) return
72
+ const reader = new FileReader()
73
+ reader.onload = (e) => {
74
+ this.imageThumbnailTarget.src = e.target.result
75
+ this.imagePreviewTarget.style.display = ""
76
+ }
77
+ reader.readAsDataURL(file)
78
+ }
79
+
80
+ // Drag-and-drop image attachment over the chat form.
81
+ onDragOver(event) {
82
+ if (!this.#dragHasImage(event)) return
83
+ event.preventDefault() // required to allow drop
84
+ if (this.hasAttachButtonTarget && this.attachButtonTarget.disabled) return
85
+ this.element.classList.add("drag-over")
86
+ }
87
+
88
+ onDragLeave(event) {
89
+ // dragleave fires when crossing into child nodes too; only clear when
90
+ // we're actually leaving the form root.
91
+ if (event.relatedTarget && this.element.contains(event.relatedTarget)) return
92
+ this.element.classList.remove("drag-over")
93
+ }
94
+
95
+ onDrop(event) {
96
+ if (!this.#dragHasImage(event)) return
97
+ event.preventDefault()
98
+ this.element.classList.remove("drag-over")
99
+ if (this.hasAttachButtonTarget && this.attachButtonTarget.disabled) return
100
+ const file = Array.from(event.dataTransfer.files || []).find((f) => f.type.startsWith("image/"))
101
+ if (!file || !this.hasImageInputTarget) return
102
+ // Populate the hidden file input via DataTransfer so the multipart form
103
+ // submit picks it up just like a click-selected file.
104
+ const dt = new DataTransfer()
105
+ dt.items.add(file)
106
+ this.imageInputTarget.files = dt.files
107
+ this.onImageSelected()
108
+ }
109
+
110
+ #dragHasImage(event) {
111
+ const items = event.dataTransfer && event.dataTransfer.items
112
+ if (!items) return false
113
+ return Array.from(items).some((it) => it.kind === "file" && it.type.startsWith("image/"))
114
+ }
115
+
116
+ // Enter to send, Shift+Enter for a newline — standard chat-UI shortcut.
117
+ // IME composition (Japanese / Chinese / Korean input) emits Enter to
118
+ // confirm a candidate; `isComposing` and the `keyCode === 229` fallback
119
+ // skip submission in that case so users don't lose mid-conversion text.
120
+ onPromptKeydown(event) {
121
+ if (event.key !== "Enter") return
122
+ if (event.shiftKey || event.isComposing || event.keyCode === 229) return
123
+ if (this.submitTarget.disabled) return
124
+ event.preventDefault()
125
+ this.element.requestSubmit(this.submitTarget)
126
+ }
127
+
128
+ // Paste-to-attach: screenshots and copied images dropped onto the
129
+ // prompt textarea get routed through the same multipart upload path
130
+ // as the file picker and drag-drop. Falls through silently when the
131
+ // selected model isn't vision-capable.
132
+ onPaste(event) {
133
+ const items = event.clipboardData && event.clipboardData.items
134
+ if (!items) return
135
+ const imageItem = Array.from(items).find(
136
+ (it) => it.kind === "file" && it.type.startsWith("image/")
137
+ )
138
+ if (!imageItem) return
139
+ if (this.hasAttachButtonTarget && this.attachButtonTarget.disabled) return
140
+ const file = imageItem.getAsFile()
141
+ if (!file || !this.hasImageInputTarget) return
142
+ event.preventDefault()
143
+ const dt = new DataTransfer()
144
+ dt.items.add(file)
145
+ this.imageInputTarget.files = dt.files
146
+ this.onImageSelected()
147
+ }
148
+
149
+ clearImage() {
150
+ if (this.hasImageInputTarget) this.imageInputTarget.value = ""
151
+ if (this.hasImageThumbnailTarget) this.imageThumbnailTarget.src = ""
152
+ if (this.hasImagePreviewTarget) this.imagePreviewTarget.style.display = "none"
153
+ }
154
+
155
+ #selectedModelSupportsVision() {
156
+ // The grid-view picker writes the chosen model into a hidden input
157
+ // and stamps data-supports-vision on it. Fall back to the legacy
158
+ // <select> shape if the page is still using the old picker.
159
+ const modelInput = document.querySelector('input[name="model"], select[name="model"]')
160
+ if (!modelInput || !modelInput.value) return false
161
+ if (modelInput.tagName === "SELECT") {
162
+ const opt = modelInput.options[modelInput.selectedIndex]
163
+ return opt && opt.dataset.supportsVision === "true"
164
+ }
165
+ return modelInput.dataset.supportsVision === "true"
13
166
  }
14
167
 
15
168
  // Handle form submission to show user message immediately
@@ -30,21 +183,36 @@ export default class extends Controller {
30
183
  return
31
184
  }
32
185
 
186
+ // Capture the in-preview thumbnail (data URL) so the optimistic bubble
187
+ // mirrors what the server will eventually render via split_attached_image_html.
188
+ const imageDataUrl = (this.hasImagePreviewTarget &&
189
+ this.imagePreviewTarget.style.display !== "none" &&
190
+ this.hasImageThumbnailTarget)
191
+ ? this.imageThumbnailTarget.src
192
+ : null
193
+
33
194
  // Add user message to the messages list immediately
34
- this.#addUserMessageToDOM(messageContent)
195
+ this.#addUserMessageToDOM(messageContent, imageDataUrl)
35
196
 
36
197
  // DON'T clear the input here - let the server response handle it
37
198
  // Otherwise the POST will be sent with empty value
199
+ // (the Turbo Stream response clears the message + image preview after submit).
38
200
 
39
201
  // Scroll to bottom
40
202
  this.#scrollToBottom()
41
203
  }
42
204
 
43
- #addUserMessageToDOM(content) {
205
+ #addUserMessageToDOM(content, imageDataUrl = null) {
44
206
  const messagesList = document.getElementById('messages-list')
45
207
  if (!messagesList) return
46
208
 
47
- // Create message HTML
209
+ // Create message HTML. When an image is attached, render it as a
210
+ // thumbnail above the text — matching `_message.html.erb`'s shape so
211
+ // the optimistic bubble doesn't visually jump when the server's
212
+ // version replaces it on next render.
213
+ const imgHtml = imageDataUrl
214
+ ? `<img src="${this.#escapeAttr(imageDataUrl)}" alt="" class="user-attached-image">`
215
+ : ""
48
216
  const messageDiv = document.createElement('div')
49
217
  messageDiv.className = 'message user'
50
218
  messageDiv.innerHTML = `
@@ -52,6 +220,7 @@ export default class extends Controller {
52
220
  👤 You
53
221
  </div>
54
222
  <div class="message-content">
223
+ ${imgHtml}
55
224
  <p>${this.#escapeHtml(content)}</p>
56
225
  </div>
57
226
  `
@@ -59,6 +228,11 @@ export default class extends Controller {
59
228
  messagesList.appendChild(messageDiv)
60
229
  }
61
230
 
231
+ // Attribute-safe escape for use inside double-quoted HTML attributes.
232
+ #escapeAttr(text) {
233
+ return String(text).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
234
+ }
235
+
62
236
  #escapeHtml(text) {
63
237
  const div = document.createElement('div')
64
238
  div.textContent = text
@@ -94,15 +268,15 @@ export default class extends Controller {
94
268
  return basicFieldsValid
95
269
  }
96
270
 
97
- // Family, API Key and Model selects require JavaScript validation
98
- const familySelect = document.querySelector('select[name="family"]')
99
- const apiKeySelect = document.querySelector('select[name="api_key_uuid"]')
100
- const modelSelect = document.querySelector('select[name="model"]')
271
+ // Family, API key, and model come from the grid picker as hidden inputs
272
+ // (legacy <select> shape is also tolerated for the old picker).
273
+ const familyInput = document.querySelector('[name="family"]')
274
+ const apiKeyInput = document.querySelector('[name="api_key_uuid"]')
275
+ const modelInput = document.querySelector('[name="model"]')
101
276
 
102
- const familySelected = familySelect?.value
103
- const apiKeyHidden = apiKeySelect?.closest(".api-key-field")?.classList.contains("hidden")
104
- const apiKeySelected = apiKeySelect?.value && (apiKeyHidden || !apiKeySelect.disabled)
105
- const modelSelected = modelSelect?.value && !modelSelect.disabled
277
+ const familySelected = !!familyInput?.value
278
+ const apiKeySelected = !!apiKeyInput?.value
279
+ const modelSelected = !!modelInput?.value && !modelInput.disabled
106
280
 
107
281
  return basicFieldsValid && familySelected && apiKeySelected && modelSelected
108
282
  }
@@ -1,8 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- const ALLOWED_KEYS = ["temperature", "top_k", "top_p", "max_tokens", "repeat_penalty"]
4
-
5
3
  // Connects to data-controller="generation-settings"
4
+ //
5
+ // The client-side validator only checks that the input is a JSON object;
6
+ // keys and value types are pass-through (matches the server: anything
7
+ // goes, and the provider gem decides which keys it understands).
6
8
  export default class extends Controller {
7
9
  static targets = [
8
10
  "toggleButton",
@@ -10,10 +12,12 @@ export default class extends Controller {
10
12
  "panel",
11
13
  "jsonInput",
12
14
  "error",
15
+ "countBadge",
13
16
  ]
14
17
 
15
18
  connect() {
16
19
  this.expanded = false
20
+ this.#updateCountBadge()
17
21
  }
18
22
 
19
23
  toggle() {
@@ -23,9 +27,11 @@ export default class extends Controller {
23
27
  this.panelTarget.style.display = this.expanded ? "block" : "none"
24
28
 
25
29
  if (this.hasToggleIconTarget) {
26
- this.toggleIconTarget.classList.toggle("bi-chevron-down", !this.expanded)
27
- this.toggleIconTarget.classList.toggle("bi-chevron-up", this.expanded)
30
+ this.toggleIconTarget.classList.toggle("bi-chevron-up", !this.expanded)
31
+ this.toggleIconTarget.classList.toggle("bi-chevron-down", this.expanded)
28
32
  }
33
+
34
+ if (this.expanded) this.dispatch("opened", { bubbles: true })
29
35
  }
30
36
 
31
37
  validate() {
@@ -33,6 +39,7 @@ export default class extends Controller {
33
39
 
34
40
  if (!input) {
35
41
  this.#clearError()
42
+ this.#updateCountBadge()
36
43
  return
37
44
  }
38
45
 
@@ -41,27 +48,18 @@ export default class extends Controller {
41
48
  parsed = JSON.parse(input)
42
49
  } catch (e) {
43
50
  this.#showError("Invalid JSON syntax")
51
+ this.#updateCountBadge()
44
52
  return
45
53
  }
46
54
 
47
55
  if (typeof parsed !== "object" || Array.isArray(parsed) || parsed === null) {
48
56
  this.#showError("Must be a JSON object (e.g. {\"temperature\": 0.7})")
49
- return
50
- }
51
-
52
- const unknownKeys = Object.keys(parsed).filter(k => !ALLOWED_KEYS.includes(k))
53
- if (unknownKeys.length > 0) {
54
- this.#showError(`Unknown keys: ${unknownKeys.join(", ")}`)
55
- return
56
- }
57
-
58
- const nonNumeric = Object.entries(parsed).filter(([, v]) => typeof v !== "number")
59
- if (nonNumeric.length > 0) {
60
- this.#showError(`Values must be numeric: ${nonNumeric.map(([k]) => k).join(", ")}`)
57
+ this.#updateCountBadge()
61
58
  return
62
59
  }
63
60
 
64
61
  this.#clearError()
62
+ this.#updateCountBadge(parsed)
65
63
  }
66
64
 
67
65
  get isValid() {
@@ -71,10 +69,7 @@ export default class extends Controller {
71
69
 
72
70
  try {
73
71
  const parsed = JSON.parse(input)
74
- if (typeof parsed !== "object" || Array.isArray(parsed) || parsed === null) return false
75
- if (Object.keys(parsed).some(k => !ALLOWED_KEYS.includes(k))) return false
76
- if (Object.values(parsed).some(v => typeof v !== "number")) return false
77
- return true
72
+ return typeof parsed === "object" && !Array.isArray(parsed) && parsed !== null
78
73
  } catch {
79
74
  return false
80
75
  }
@@ -95,4 +90,27 @@ export default class extends Controller {
95
90
  }
96
91
  this.jsonInputTarget.classList.remove("generation-settings-json-input--invalid")
97
92
  }
93
+
94
+ // Count of top-level keys currently set on the (valid) Settings JSON.
95
+ // Caller can pass the already-parsed hash to avoid a second JSON.parse;
96
+ // when omitted we re-parse the textarea (and treat invalid / empty as 0).
97
+ #updateCountBadge(parsedOpt) {
98
+ if (!this.hasCountBadgeTarget) return
99
+ let count = 0
100
+ if (parsedOpt && typeof parsedOpt === "object" && !Array.isArray(parsedOpt)) {
101
+ count = Object.keys(parsedOpt).length
102
+ } else if (this.hasJsonInputTarget) {
103
+ const input = this.jsonInputTarget.value.trim()
104
+ if (input) {
105
+ try {
106
+ const parsed = JSON.parse(input)
107
+ if (typeof parsed === "object" && !Array.isArray(parsed) && parsed !== null) {
108
+ count = Object.keys(parsed).length
109
+ }
110
+ } catch { /* invalid → 0 */ }
111
+ }
112
+ }
113
+ this.countBadgeTarget.textContent = count
114
+ this.countBadgeTarget.style.display = count > 0 ? "inline-block" : "none"
115
+ }
98
116
  }
@@ -0,0 +1,55 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Coordinates the LLM / Tools / Settings panels so only one is open at a time.
4
+ // Each child controller dispatches an "<identifier>:opened" event when its
5
+ // panel opens; this controller closes the other ones. Also closes any open
6
+ // panel on outside-click or Escape so the user can quickly return to typing.
7
+ const CHILDREN = ["llm-toggle", "tool-selector", "generation-settings"]
8
+
9
+ export default class extends Controller {
10
+ connect() {
11
+ this._onOpened = (event) => {
12
+ const openedBy = event.target.closest("[data-controller~='" + CHILDREN.join("'], [data-controller~='") + "']")
13
+ if (!openedBy) return
14
+ CHILDREN.forEach((ident) => {
15
+ if (openedBy.matches(`[data-controller~="${ident}"]`)) return
16
+ this.#closeChild(ident)
17
+ })
18
+ }
19
+ CHILDREN.forEach((ident) => {
20
+ this.element.addEventListener(`${ident}:opened`, this._onOpened)
21
+ })
22
+
23
+ this._onDocClick = (event) => {
24
+ if (this.element.contains(event.target)) return
25
+ this.#closeAll()
26
+ }
27
+ document.addEventListener("click", this._onDocClick)
28
+
29
+ this._onKeydown = (event) => {
30
+ if (event.key === "Escape") this.#closeAll()
31
+ }
32
+ document.addEventListener("keydown", this._onKeydown)
33
+ }
34
+
35
+ disconnect() {
36
+ if (this._onOpened) {
37
+ CHILDREN.forEach((ident) => {
38
+ this.element.removeEventListener(`${ident}:opened`, this._onOpened)
39
+ })
40
+ }
41
+ if (this._onDocClick) document.removeEventListener("click", this._onDocClick)
42
+ if (this._onKeydown) document.removeEventListener("keydown", this._onKeydown)
43
+ }
44
+
45
+ #closeChild(ident) {
46
+ const el = this.element.querySelector(`[data-controller~="${ident}"]`)
47
+ if (!el) return
48
+ const ctrl = this.application.getControllerForElementAndIdentifier(el, ident)
49
+ if (ctrl?.expanded) ctrl.toggle()
50
+ }
51
+
52
+ #closeAll() {
53
+ CHILDREN.forEach((ident) => this.#closeChild(ident))
54
+ }
55
+ }
@@ -0,0 +1,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="llm-toggle"
4
+ // Collapsible wrapper around the "Other models" grid panel. Label is
5
+ // static — the currently picked model is shown by the quick-picks row.
6
+ export default class extends Controller {
7
+ static targets = ["toggleButton", "toggleIcon", "panel", "label"]
8
+
9
+ connect() {
10
+ this.expanded = false
11
+ }
12
+
13
+ toggle() {
14
+ if (!this.hasPanelTarget) return
15
+
16
+ this.expanded = !this.expanded
17
+ this.panelTarget.style.display = this.expanded ? "block" : "none"
18
+
19
+ if (this.hasToggleIconTarget) {
20
+ this.toggleIconTarget.classList.toggle("bi-chevron-up", !this.expanded)
21
+ this.toggleIconTarget.classList.toggle("bi-chevron-down", this.expanded)
22
+ }
23
+
24
+ if (this.expanded) this.dispatch("opened", { bubbles: true })
25
+ }
26
+
27
+ }