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
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import { Controller } from "@hotwired/stimulus"
|
|
2
|
-
|
|
3
|
-
// Connects to data-controller="llm-selector"
|
|
4
|
-
export default class extends Controller {
|
|
5
|
-
static targets = ["family", "apiKey", "model"]
|
|
6
|
-
|
|
7
|
-
connect() {
|
|
8
|
-
this.#setDefaults()
|
|
9
|
-
this.dispatch("changed")
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
familyChanged(event) {
|
|
13
|
-
const selectedFamily = event.target.value
|
|
14
|
-
const familiesData = event.target.dataset.families
|
|
15
|
-
|
|
16
|
-
if (!selectedFamily || !familiesData) {
|
|
17
|
-
this.#showApiKeyField()
|
|
18
|
-
this.#clearApiKeySelect()
|
|
19
|
-
this.#clearModelSelect()
|
|
20
|
-
return
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
const families = JSON.parse(familiesData)
|
|
25
|
-
const family = families.find((f) => f.llm_type === selectedFamily)
|
|
26
|
-
|
|
27
|
-
if (family?.api_keys) {
|
|
28
|
-
if (selectedFamily === "ollama") {
|
|
29
|
-
// Ollama: skip API key selection entirely, go straight to model
|
|
30
|
-
const apiKey = family.api_keys[0]
|
|
31
|
-
this.#hideApiKeyField()
|
|
32
|
-
this.apiKeyTarget.disabled = false
|
|
33
|
-
this.apiKeyTarget.innerHTML =
|
|
34
|
-
`<option value="${apiKey.uuid}" selected>${apiKey.description}</option>`
|
|
35
|
-
if (apiKey.available_models) {
|
|
36
|
-
this.#populateModelSelect(apiKey.available_models)
|
|
37
|
-
} else {
|
|
38
|
-
this.#clearModelSelect()
|
|
39
|
-
}
|
|
40
|
-
} else {
|
|
41
|
-
this.#showApiKeyField()
|
|
42
|
-
this.#populateApiKeySelect(family.api_keys)
|
|
43
|
-
}
|
|
44
|
-
} else {
|
|
45
|
-
this.#showApiKeyField()
|
|
46
|
-
this.#clearApiKeySelect()
|
|
47
|
-
this.#clearModelSelect()
|
|
48
|
-
}
|
|
49
|
-
} catch (e) {
|
|
50
|
-
console.error("Failed to parse families data:", e)
|
|
51
|
-
this.#clearApiKeySelect()
|
|
52
|
-
this.#clearModelSelect()
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
apiKeyChanged(event) {
|
|
57
|
-
const selectedValue = event.target.value
|
|
58
|
-
const familiesData = this.hasFamilyTarget
|
|
59
|
-
? this.familyTarget.dataset.families
|
|
60
|
-
: null
|
|
61
|
-
const selectedFamily = this.hasFamilyTarget
|
|
62
|
-
? this.familyTarget.value
|
|
63
|
-
: null
|
|
64
|
-
|
|
65
|
-
if (!selectedValue || !familiesData || !selectedFamily) {
|
|
66
|
-
this.#clearModelSelect()
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
const families = JSON.parse(familiesData)
|
|
72
|
-
const family = families.find((f) => f.llm_type === selectedFamily)
|
|
73
|
-
const selectedKey = family?.api_keys?.find(
|
|
74
|
-
(k) => k.uuid === selectedValue
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
if (selectedKey?.available_models) {
|
|
78
|
-
this.#populateModelSelect(selectedKey.available_models)
|
|
79
|
-
} else {
|
|
80
|
-
this.#clearModelSelect()
|
|
81
|
-
}
|
|
82
|
-
} catch (e) {
|
|
83
|
-
console.error("Failed to parse families data:", e)
|
|
84
|
-
this.#clearModelSelect()
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
#setDefaults() {
|
|
89
|
-
const urlParams = new URLSearchParams(window.location.search)
|
|
90
|
-
const defaultFamily = urlParams.get("family")
|
|
91
|
-
const defaultApiKey = urlParams.get("api_key_uuid")
|
|
92
|
-
const defaultModel = urlParams.get("model")
|
|
93
|
-
|
|
94
|
-
if (defaultFamily && this.hasFamilyTarget) {
|
|
95
|
-
const familyOption = Array.from(this.familyTarget.options).find(
|
|
96
|
-
(o) => o.value === defaultFamily
|
|
97
|
-
)
|
|
98
|
-
if (familyOption) {
|
|
99
|
-
this.familyTarget.value = familyOption.value
|
|
100
|
-
this.familyChanged({ target: this.familyTarget })
|
|
101
|
-
|
|
102
|
-
if (defaultFamily === "ollama") {
|
|
103
|
-
// Ollama: API key is auto-selected by familyChanged, just set model
|
|
104
|
-
if (defaultModel && this.hasModelTarget) {
|
|
105
|
-
const modelOption = Array.from(this.modelTarget.options).find(
|
|
106
|
-
(o) => o.value === defaultModel
|
|
107
|
-
)
|
|
108
|
-
if (modelOption) {
|
|
109
|
-
this.modelTarget.value = modelOption.value
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
} else if (defaultApiKey && this.hasApiKeyTarget) {
|
|
113
|
-
const apiKeyOption = Array.from(this.apiKeyTarget.options).find(
|
|
114
|
-
(o) => o.value === defaultApiKey
|
|
115
|
-
)
|
|
116
|
-
if (apiKeyOption) {
|
|
117
|
-
this.apiKeyTarget.value = apiKeyOption.value
|
|
118
|
-
this.apiKeyChanged({ target: this.apiKeyTarget })
|
|
119
|
-
|
|
120
|
-
if (defaultModel && this.hasModelTarget) {
|
|
121
|
-
const modelOption = Array.from(this.modelTarget.options).find(
|
|
122
|
-
(o) => o.value === defaultModel
|
|
123
|
-
)
|
|
124
|
-
if (modelOption) {
|
|
125
|
-
this.modelTarget.value = modelOption.value
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
} else if (defaultApiKey && this.hasFamilyTarget) {
|
|
132
|
-
// Fallback: try to find the family from the API key UUID
|
|
133
|
-
this.#setDefaultsFromApiKey(defaultApiKey, defaultModel)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
#setDefaultsFromApiKey(apiKeyUuid, defaultModel) {
|
|
138
|
-
const familiesData = this.hasFamilyTarget
|
|
139
|
-
? this.familyTarget.dataset.families
|
|
140
|
-
: null
|
|
141
|
-
if (!familiesData) return
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
const families = JSON.parse(familiesData)
|
|
145
|
-
for (const family of families) {
|
|
146
|
-
const key = family.api_keys?.find((k) => k.uuid === apiKeyUuid)
|
|
147
|
-
if (key) {
|
|
148
|
-
this.familyTarget.value = family.llm_type
|
|
149
|
-
this.familyChanged({ target: this.familyTarget })
|
|
150
|
-
|
|
151
|
-
if (family.llm_type !== "ollama") {
|
|
152
|
-
this.apiKeyTarget.value = apiKeyUuid
|
|
153
|
-
this.apiKeyChanged({ target: this.apiKeyTarget })
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (defaultModel && this.hasModelTarget) {
|
|
157
|
-
const modelOption = Array.from(this.modelTarget.options).find(
|
|
158
|
-
(o) => o.value === defaultModel
|
|
159
|
-
)
|
|
160
|
-
if (modelOption) {
|
|
161
|
-
this.modelTarget.value = modelOption.value
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
break
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
} catch (e) {
|
|
168
|
-
console.error("Failed to set defaults from API key:", e)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
#populateApiKeySelect(apiKeys) {
|
|
173
|
-
if (!this.hasApiKeyTarget) return
|
|
174
|
-
|
|
175
|
-
this.apiKeyTarget.innerHTML =
|
|
176
|
-
'<option value="">Please select a service</option>'
|
|
177
|
-
this.apiKeyTarget.disabled = false
|
|
178
|
-
|
|
179
|
-
for (const key of apiKeys) {
|
|
180
|
-
const option = document.createElement("option")
|
|
181
|
-
option.value = key.uuid
|
|
182
|
-
option.textContent = key.description
|
|
183
|
-
this.apiKeyTarget.appendChild(option)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Clear model when API key list changes
|
|
187
|
-
this.#clearModelSelect()
|
|
188
|
-
this.dispatch("changed")
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
#clearApiKeySelect() {
|
|
192
|
-
if (!this.hasApiKeyTarget) return
|
|
193
|
-
|
|
194
|
-
this.apiKeyTarget.innerHTML =
|
|
195
|
-
'<option value="">Please select a family first</option>'
|
|
196
|
-
this.apiKeyTarget.disabled = true
|
|
197
|
-
this.#clearModelSelect()
|
|
198
|
-
this.dispatch("changed")
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
#hideApiKeyField() {
|
|
202
|
-
if (!this.hasApiKeyTarget) return
|
|
203
|
-
this.apiKeyTarget.closest(".api-key-field").classList.add("hidden")
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
#showApiKeyField() {
|
|
207
|
-
if (!this.hasApiKeyTarget) return
|
|
208
|
-
this.apiKeyTarget.closest(".api-key-field").classList.remove("hidden")
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
#populateModelSelect(models) {
|
|
212
|
-
if (!this.hasModelTarget) return
|
|
213
|
-
|
|
214
|
-
this.modelTarget.innerHTML =
|
|
215
|
-
'<option value="">Please select a model</option>'
|
|
216
|
-
this.modelTarget.disabled = false
|
|
217
|
-
|
|
218
|
-
for (const model of models) {
|
|
219
|
-
const option = document.createElement("option")
|
|
220
|
-
option.value = model.value
|
|
221
|
-
option.textContent = model.label
|
|
222
|
-
this.modelTarget.appendChild(option)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
this.dispatch("changed")
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
#clearModelSelect() {
|
|
229
|
-
if (!this.hasModelTarget) return
|
|
230
|
-
|
|
231
|
-
this.modelTarget.innerHTML =
|
|
232
|
-
'<option value="">Please select a service first</option>'
|
|
233
|
-
this.modelTarget.disabled = true
|
|
234
|
-
this.dispatch("changed")
|
|
235
|
-
}
|
|
236
|
-
}
|
data/lib/generators/llm_meta_client/scaffold/templates/app/views/chats/update.turbo_stream.erb
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
<%% # Clear the message input %>
|
|
2
|
-
<%%= turbo_stream.update "message-input" do %>
|
|
3
|
-
<%% end %>
|
|
4
|
-
|
|
5
|
-
<%% # User message is already shown by JavaScript on form submit %>
|
|
6
|
-
<%% # Only render assistant message here %>
|
|
7
|
-
|
|
8
|
-
<%%# Render streaming assistant placeholder; the message-stream Stimulus controller opens an EventSource and appends deltas as they arrive. %>
|
|
9
|
-
<%% if @prompt_execution && @error_message.blank? %>
|
|
10
|
-
<%%= turbo_stream.append "messages-list" do %>
|
|
11
|
-
<%%= render partial: "chats/streaming_message", locals: { chat: @chat, prompt_execution: @prompt_execution } %>
|
|
12
|
-
<%% end %>
|
|
13
|
-
<%% end %>
|
|
14
|
-
|
|
15
|
-
<%% # Show error message if any %>
|
|
16
|
-
<%% if @error_message %>
|
|
17
|
-
<%%= turbo_stream.append "messages-list" do %>
|
|
18
|
-
<div class="message error">
|
|
19
|
-
<div class="message-content">
|
|
20
|
-
<p><%%= @error_message %></p>
|
|
21
|
-
</div>
|
|
22
|
-
</div>
|
|
23
|
-
<%% end %>
|
|
24
|
-
<%% end %>
|
|
25
|
-
|
|
26
|
-
<%% # Update history sidebar - replace entire content to ensure update %>
|
|
27
|
-
<%% if @prompt_execution %>
|
|
28
|
-
<%%= turbo_stream.replace "history-sidebar" do %>
|
|
29
|
-
<div id="history-sidebar">
|
|
30
|
-
<h2>History</h2>
|
|
31
|
-
<div class="history-stack" id="history-stack" data-controller="history">
|
|
32
|
-
<%%= render 'prompt_navigator/history_card', locals: {
|
|
33
|
-
ann: @prompt_execution,
|
|
34
|
-
next_ann: (@chat&.ordered_by_descending_prompt_executions || [])[1],
|
|
35
|
-
is_active: @prompt_execution.execution_id == @active_message_uuid,
|
|
36
|
-
card_path: ->(uuid) { prompt_path(uuid) }
|
|
37
|
-
} %>
|
|
38
|
-
<%% (@chat&.ordered_by_descending_prompt_executions || []).drop(1).each_with_index do |ann, idx| %>
|
|
39
|
-
<%%= render 'prompt_navigator/history_card', locals: {
|
|
40
|
-
ann: ann,
|
|
41
|
-
next_ann: @chat.ordered_by_descending_prompt_executions[idx + 2],
|
|
42
|
-
is_active: ann.execution_id == @active_message_uuid,
|
|
43
|
-
card_path: ->(uuid) { prompt_path(uuid) }
|
|
44
|
-
} %>
|
|
45
|
-
<%% end %>
|
|
46
|
-
<svg class="history-arrows" data-history-target="svg"></svg>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
<%% end %>
|
|
50
|
-
<%% end %>
|
|
51
|
-
|
|
52
|
-
<%% # Clear input, refocus, and scroll %>
|
|
53
|
-
<turbo-stream action="after" target="messages-list">
|
|
54
|
-
<template>
|
|
55
|
-
<script>
|
|
56
|
-
(function() {
|
|
57
|
-
// Clear and refocus message input
|
|
58
|
-
const messageInput = document.getElementById('message-input');
|
|
59
|
-
if (messageInput) {
|
|
60
|
-
messageInput.value = '';
|
|
61
|
-
messageInput.focus();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Update submit button state
|
|
65
|
-
const form = document.querySelector('[data-controller="chats-form"]');
|
|
66
|
-
if (form && messageInput) {
|
|
67
|
-
const event = new Event('input', { bubbles: true });
|
|
68
|
-
messageInput.dispatchEvent(event);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Update branch_from_uuid to the latest prompt execution
|
|
72
|
-
const branchField = document.getElementById('branch_from_uuid');
|
|
73
|
-
if (branchField) {
|
|
74
|
-
branchField.value = '<%%= @prompt_execution&.execution_id %>';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Scroll to bottom
|
|
78
|
-
const chatMessages = document.getElementById('chat-messages');
|
|
79
|
-
if (chatMessages) {
|
|
80
|
-
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
81
|
-
}
|
|
82
|
-
})();
|
|
83
|
-
</script>
|
|
84
|
-
</template>
|
|
85
|
-
</turbo-stream>
|
data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_api_key_field.html.erb
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
<%%
|
|
2
|
-
stimulus_controller = local_assigns[:stimulus_controller]
|
|
3
|
-
# Convert hyphens to underscores for Stimulus targets on the Rails side
|
|
4
|
-
stimulus_target_prefix = stimulus_controller.gsub('-', '_')
|
|
5
|
-
%>
|
|
6
|
-
<div class="api-key-field">
|
|
7
|
-
<label>LLM Service</label>
|
|
8
|
-
<%%= select_tag :api_key_uuid,
|
|
9
|
-
options_for_select([["Please select a family first", ""]]),
|
|
10
|
-
{ required: true, disabled: true,
|
|
11
|
-
data: {
|
|
12
|
-
"#{stimulus_target_prefix}-target": "apiKey",
|
|
13
|
-
action: "change->#{stimulus_controller}#apiKeyChanged"
|
|
14
|
-
} } %>
|
|
15
|
-
</div>
|
data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_family_field.html.erb
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
<%%
|
|
2
|
-
llm_families = local_assigns[:llm_families] || []
|
|
3
|
-
stimulus_controller = local_assigns[:stimulus_controller]
|
|
4
|
-
# Convert hyphens to underscores for Stimulus targets on the Rails side
|
|
5
|
-
stimulus_target_prefix = stimulus_controller.gsub('-', '_')
|
|
6
|
-
%>
|
|
7
|
-
<div class="family-field">
|
|
8
|
-
<label>Family</label>
|
|
9
|
-
<%%= select_tag :family,
|
|
10
|
-
options_for_select([["Please select a family", ""]] +
|
|
11
|
-
llm_families.map { |f| [f[:name], f[:llm_type]] }),
|
|
12
|
-
{ required: true,
|
|
13
|
-
data: {
|
|
14
|
-
"#{stimulus_target_prefix}-target": "family",
|
|
15
|
-
action: "change->#{stimulus_controller}#familyChanged",
|
|
16
|
-
families: llm_families.to_json
|
|
17
|
-
} } %>
|
|
18
|
-
</div>
|
data/lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_model_field.html.erb
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
<%%
|
|
2
|
-
stimulus_controller = local_assigns[:stimulus_controller]
|
|
3
|
-
# Convert hyphens to underscores for Stimulus targets on the Rails side
|
|
4
|
-
stimulus_target_prefix = stimulus_controller.gsub('-', '_')
|
|
5
|
-
%>
|
|
6
|
-
|
|
7
|
-
<div class="model-field">
|
|
8
|
-
<label>LLM model</label>
|
|
9
|
-
<%%= select_tag :model, options_for_select([["Please select a service first", ""]]),
|
|
10
|
-
{ required: true, disabled: true,
|
|
11
|
-
data: { "#{stimulus_target_prefix}-target": "model" } } %>
|
|
12
|
-
</div>
|