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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/lib/generators/llm_meta_client/scaffold/scaffold_generator.rb +12 -7
- data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/api/mcp_servers_controller.rb +2 -2
- data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/chat_streams_controller.rb +24 -2
- data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/chats_controller.rb +92 -76
- data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/prompts_controller.rb +28 -1
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/asset_actions_controller.js +98 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_controller.js +126 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_menu_controller.js +42 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_title_edit_controller.js +5 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chats_form_controller.js +186 -12
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/generation_settings_controller.js +38 -20
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/input_controls_controller.js +55 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/llm_toggle_controller.js +27 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/message_stream_controller.js +102 -3
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/model_picker_controller.js +160 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/tool_selector_controller.js +10 -2
- data/lib/generators/llm_meta_client/scaffold/templates/app/models/chat.rb +130 -44
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_chat_sidebar.html.erb +3 -1
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_message.html.erb +3 -1
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_streaming_message.html.erb +6 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_tool_call_message.html.erb +20 -18
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/create.turbo_stream.erb +31 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/destroy.turbo_stream.erb +3 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/edit.html.erb +53 -17
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/new.html.erb +50 -17
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_header.html.erb +1 -5
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_new_chat_button.html.erb +7 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/application.html.erb +2 -2
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_generation_settings_field.html.erb +7 -5
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_model_grid.html.erb +88 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_quick_picks.html.erb +67 -0
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_tool_selector_field.html.erb +1 -1
- data/lib/llm_meta_client/helpers.rb +18 -0
- data/lib/llm_meta_client/server_query.rb +24 -6
- data/lib/llm_meta_client/version.rb +1 -1
- metadata +11 -6
- data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/llm_selector_controller.js +0 -236
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/update.turbo_stream.erb +0 -85
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_api_key_field.html.erb +0 -15
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_family_field.html.erb +0 -18
- data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_model_field.html.erb +0 -12
data/lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_controller.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -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 = [
|
|
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">")
|
|
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
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
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 =
|
|
103
|
-
const
|
|
104
|
-
const
|
|
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-
|
|
27
|
-
this.toggleIconTarget.classList.toggle("bi-chevron-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|