llm_meta_client 1.0.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd284d6a68a9bbad852fbcb19054ce2aeda512f0ffe17c2ac7029eb30907a7e4
4
- data.tar.gz: 5fe0cd2beef4c9bbc7fb75ee49099558a07626e0affa6b9ba9b065eca940dc67
3
+ metadata.gz: 5dde8e658e36a04b651c91bfcbd34d23b639449c4781529cd5c061e49e52cc53
4
+ data.tar.gz: 56d464e06df9afb9a92ef5dcafee8d806158fb1c333dc5a44800a1737095e68b
5
5
  SHA512:
6
- metadata.gz: 53f9fcccc2ffee1c08ba3df22bf95e08bc857d8da0cd748f520f26a7144b03dded4a3ceb4ca67b3108fda12d79f521ed88e5ce0b30cb8b232ac5185941d9832f
7
- data.tar.gz: e5529bc832a453030d348cd8d01100bb664896d03cee385f83ee43b945c316b0c1d03d4227c65b23d6b360943e91e1c9727e7d52d1311b107eacf2bf7f58799f
6
+ metadata.gz: 3a5442c238211a0432a26e54cd7923c1782d11178679a694cb2ac60ddb3bbd02365431bf0bbba61c9f505e65d1c3b8f60303a23dbb4752813fda580bfb997a4f
7
+ data.tar.gz: 955f0bb38816e24504041962e6b2a4cd9531ea89ad805c503624de927c9339383276c146dad1b346f64521f0cd3dfed985d2b5707676efdad27e998eb3441f60
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.2] - 2026-03-27
9
+
10
+ ### Added
11
+
12
+ - Add client-side validation for Generation Settings JSON
13
+
14
+ ## [1.0.1] - 2026-03-25
15
+
16
+ ### Fixed
17
+
18
+ - Fix: normalize Ollama llm_type in server resource options
19
+ - Fix: update branch_from_uuid after LLM response
20
+
21
+ ### Changed
22
+
23
+ - Refactor: move llm_uuid and model from Chat to PromptExecution
24
+
8
25
  ## [1.0.0] - 2026-03-25
9
26
 
10
27
  ### Changed
@@ -62,6 +62,21 @@
62
62
  }
63
63
  }
64
64
 
65
+ .generation-settings-json-input--invalid {
66
+ border-color: #ef4444;
67
+
68
+ &:focus {
69
+ border-color: #ef4444;
70
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
71
+ }
72
+ }
73
+
74
+ .generation-settings-error {
75
+ font-size: 12px;
76
+ color: #ef4444;
77
+ margin-top: 4px;
78
+ }
79
+
65
80
  .generation-settings-hint {
66
81
  font-size: 11px;
67
82
  color: #9ca3af;
@@ -56,6 +56,7 @@ module LlmMetaClient
56
56
  def add_migrations
57
57
  migration_template "db/migrate/create_chats.rb", "db/migrate/create_chats.rb"
58
58
  migration_template "db/migrate/create_messages.rb", "db/migrate/create_messages.rb"
59
+ migration_template "db/migrate/migrate_llm_uuid_to_prompt_executions.rb", "db/migrate/migrate_llm_uuid_to_prompt_executions.rb"
59
60
  end
60
61
 
61
62
  def configure_routes
@@ -61,9 +61,7 @@ class ChatsController < ApplicationController
61
61
  # Find or create chat
62
62
  @chat = Chat.find_or_switch_for_session(
63
63
  session,
64
- current_user,
65
- llm_uuid: params[:api_key_uuid],
66
- model: params[:model]
64
+ current_user
67
65
  )
68
66
  add_chat @chat
69
67
  @messages = @chat&.ordered_messages || []
@@ -86,6 +84,7 @@ class ChatsController < ApplicationController
86
84
 
87
85
  # Add user message (will be rendered via turbo stream)
88
86
  @prompt_execution, @user_message = @chat.add_user_message(params[:message],
87
+ params[:api_key_uuid],
89
88
  params[:model],
90
89
  params[:branch_from_uuid])
91
90
  # Push to history for rendering
@@ -144,9 +143,7 @@ class ChatsController < ApplicationController
144
143
 
145
144
  @chat = Chat.find_or_switch_for_session(
146
145
  session,
147
- current_user,
148
- llm_uuid: params[:api_key_uuid],
149
- model: params[:model]
146
+ current_user
150
147
  )
151
148
  @messages = @chat&.ordered_messages || []
152
149
  # initialize history for the chat
@@ -184,6 +181,7 @@ class ChatsController < ApplicationController
184
181
 
185
182
  # Add user message (will be rendered via turbo stream)
186
183
  @prompt_execution, @user_message = @chat.add_user_message(params[:message],
184
+ params[:api_key_uuid],
187
185
  params[:model],
188
186
  params[:branch_from_uuid])
189
187
  # Push to history for rendering
@@ -13,7 +13,15 @@ export default class extends Controller {
13
13
  }
14
14
 
15
15
  // Handle form submission to show user message immediately
16
- submit() {
16
+ submit(event) {
17
+ // Check generation settings validity before submitting
18
+ const gsController = this.#generationSettingsController()
19
+ if (gsController && !gsController.isValid) {
20
+ event.preventDefault()
21
+ gsController.validate()
22
+ return
23
+ }
24
+
17
25
  // Don't prevent default - let Turbo handle the form submission
18
26
  // Just add the user message to the DOM immediately
19
27
  const messageContent = this.promptTarget.value.trim()
@@ -64,6 +72,12 @@ export default class extends Controller {
64
72
  }
65
73
  }
66
74
 
75
+ #generationSettingsController() {
76
+ const el = this.element.querySelector('[data-controller*="generation-settings"]')
77
+ if (!el) return null
78
+ return this.application.getControllerForElementAndIdentifier(el, "generation-settings")
79
+ }
80
+
67
81
  #canSubmit() {
68
82
  // Text field and prompt field can be validated using HTML5's required attribute,
69
83
  // so we delegate to checkValidity() to utilize standard validation
@@ -1,5 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ const ALLOWED_KEYS = ["temperature", "top_k", "top_p", "max_tokens", "repeat_penalty"]
4
+
3
5
  // Connects to data-controller="generation-settings"
4
6
  export default class extends Controller {
5
7
  static targets = [
@@ -7,6 +9,7 @@ export default class extends Controller {
7
9
  "toggleIcon",
8
10
  "panel",
9
11
  "jsonInput",
12
+ "error",
10
13
  ]
11
14
 
12
15
  connect() {
@@ -24,4 +27,72 @@ export default class extends Controller {
24
27
  this.toggleIconTarget.classList.toggle("bi-chevron-up", this.expanded)
25
28
  }
26
29
  }
30
+
31
+ validate() {
32
+ const input = this.jsonInputTarget.value.trim()
33
+
34
+ if (!input) {
35
+ this.#clearError()
36
+ return
37
+ }
38
+
39
+ let parsed
40
+ try {
41
+ parsed = JSON.parse(input)
42
+ } catch (e) {
43
+ this.#showError("Invalid JSON syntax")
44
+ return
45
+ }
46
+
47
+ if (typeof parsed !== "object" || Array.isArray(parsed) || parsed === null) {
48
+ 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(", ")}`)
61
+ return
62
+ }
63
+
64
+ this.#clearError()
65
+ }
66
+
67
+ get isValid() {
68
+ if (!this.hasJsonInputTarget) return true
69
+ const input = this.jsonInputTarget.value.trim()
70
+ if (!input) return true
71
+
72
+ try {
73
+ 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
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ #showError(message) {
84
+ if (this.hasErrorTarget) {
85
+ this.errorTarget.textContent = message
86
+ this.errorTarget.style.display = "block"
87
+ }
88
+ this.jsonInputTarget.classList.add("generation-settings-json-input--invalid")
89
+ }
90
+
91
+ #clearError() {
92
+ if (this.hasErrorTarget) {
93
+ this.errorTarget.textContent = ""
94
+ this.errorTarget.style.display = "none"
95
+ }
96
+ this.jsonInputTarget.classList.remove("generation-settings-json-input--invalid")
97
+ }
27
98
  }
@@ -6,23 +6,14 @@ class Chat < ApplicationRecord
6
6
 
7
7
  before_create :set_uuid
8
8
 
9
- validates :llm_uuid, presence: true
10
- validates :model, presence: true
11
-
12
9
  # Find existing chat from session or create new one
13
10
  class << self
14
- def find_or_switch_for_session(session, current_user, llm_uuid: nil, model: nil)
11
+ def find_or_switch_for_session(session, current_user)
15
12
  chat = find_by_session_chat_id(session, current_user)
16
- return chat if llm_uuid.nil? || model.nil?
17
-
18
- if chat.present?
19
- # Update LLM/model on existing chat if changed
20
- chat.update!(llm_uuid: llm_uuid, model: model) if chat.needs_reset?(llm_uuid, model)
21
- else
22
- chat = create!(user: current_user, llm_uuid: llm_uuid, model: model)
23
- session[:chat_id] = chat.id
24
- end
13
+ return chat if chat.present?
25
14
 
15
+ chat = create!(user: current_user)
16
+ session[:chat_id] = chat.id
26
17
  chat
27
18
  end
28
19
 
@@ -39,15 +30,8 @@ class Chat < ApplicationRecord
39
30
  end
40
31
  end
41
32
 
42
- # Get the LLM type for this chat
43
- def llm_type(jwt_token)
44
- llm_options = LlmMetaClient::ServerResource.available_llm_options(jwt_token)
45
- selected_llm = llm_options.find { |opt| opt[:uuid] == llm_uuid }
46
- selected_llm&.dig(:llm_type) || "unknown"
47
- end
48
-
49
33
  # Add a user message to the chat
50
- def add_user_message(message, model, branch_from_execution_id = nil)
34
+ def add_user_message(message, llm_uuid, model, branch_from_execution_id = nil)
51
35
  previous_id = if branch_from_execution_id.present?
52
36
  PromptNavigator::PromptExecution.find_by(execution_id: branch_from_execution_id)&.id
53
37
  else
@@ -55,6 +39,7 @@ class Chat < ApplicationRecord
55
39
  end
56
40
  prompt_execution = PromptNavigator::PromptExecution.create!(
57
41
  prompt: message,
42
+ llm_uuid: llm_uuid,
58
43
  model: model,
59
44
  configuration: "",
60
45
  previous_id: previous_id
@@ -70,9 +55,9 @@ class Chat < ApplicationRecord
70
55
 
71
56
  # Add assistant response by sending to LLM
72
57
  def add_assistant_response(prompt_execution, jwt_token, tool_ids: [], generation_settings: {})
73
- response_content = send_to_llm(jwt_token, tool_ids: tool_ids, generation_settings: generation_settings)
58
+ response_content = send_to_llm(prompt_execution, jwt_token, tool_ids: tool_ids, generation_settings: generation_settings)
74
59
  prompt_execution.update!(
75
- llm_platform: llm_type(jwt_token),
60
+ llm_platform: resolve_llm_type(prompt_execution.llm_uuid, jwt_token),
76
61
  response: response_content
77
62
  )
78
63
  new_message = messages.create!(
@@ -100,19 +85,24 @@ class Chat < ApplicationRecord
100
85
  .map(&:prompt_navigator_prompt_execution)
101
86
  end
102
87
 
103
- # Check if chat needs to be reset due to LLM or model change
104
- def needs_reset?(new_llm_uuid, new_model)
105
- llm_uuid != new_llm_uuid || model != new_model
106
- end
107
-
108
88
  private
109
89
 
90
+ # Resolve the LLM type (e.g. "openai", "google") from a given llm_uuid
91
+ def resolve_llm_type(llm_uuid, jwt_token)
92
+ llm_options = LlmMetaClient::ServerResource.available_llm_options(jwt_token)
93
+ selected_llm = llm_options.find { |opt| opt[:uuid] == llm_uuid }
94
+ selected_llm&.dig(:llm_type) || "unknown"
95
+ end
96
+
110
97
  # Summarize the user's prompt into a short title via LLM (required by ChatManager::TitleGeneratable)
111
98
  def summarize_for_title(prompt_text, jwt_token)
99
+ latest_pe = ordered_by_descending_prompt_executions.first
100
+ return nil unless latest_pe&.llm_uuid && latest_pe&.model
101
+
112
102
  LlmMetaClient::ServerQuery.new.call(
113
103
  jwt_token,
114
- llm_uuid,
115
- model,
104
+ latest_pe.llm_uuid,
105
+ latest_pe.model,
116
106
  "No context available.",
117
107
  { role: "user", prompt: "Please summarize the following text into a short title (max 50 characters). Respond with only the title, nothing else: #{prompt_text}" }
118
108
  )
@@ -124,7 +114,10 @@ class Chat < ApplicationRecord
124
114
  end
125
115
 
126
116
  # Send messages to LLM and get response
127
- def send_to_llm(jwt_token, tool_ids: [], generation_settings: {})
117
+ def send_to_llm(prompt_execution, jwt_token, tool_ids: [], generation_settings: {})
118
+ llm_uuid = prompt_execution.llm_uuid
119
+ model = prompt_execution.model
120
+
128
121
  # Get LLM options
129
122
  llm_options = LlmMetaClient::ServerResource.available_llm_options(jwt_token)
130
123
 
@@ -75,6 +75,12 @@
75
75
  messageInput.dispatchEvent(event);
76
76
  }
77
77
 
78
+ // Update branch_from_uuid to the latest prompt execution
79
+ const branchField = document.getElementById('branch_from_uuid');
80
+ if (branchField) {
81
+ branchField.value = '<%%= @prompt_execution&.execution_id %>';
82
+ }
83
+
78
84
  // Scroll to bottom
79
85
  const chatMessages = document.getElementById('chat-messages');
80
86
  if (chatMessages) {
@@ -68,6 +68,12 @@
68
68
  messageInput.dispatchEvent(event);
69
69
  }
70
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
+
71
77
  // Scroll to bottom
72
78
  const chatMessages = document.getElementById('chat-messages');
73
79
  if (chatMessages) {
@@ -20,7 +20,9 @@
20
20
  class="generation-settings-json-input"
21
21
  rows="8"
22
22
  placeholder='{"temperature": 0.7, "top_k": 40, "top_p": 0.9, "max_tokens": 4096, "repeat_penalty": 1.1}'
23
- data-<%%= stimulus_controller %>-target="jsonInput"></textarea>
23
+ data-<%%= stimulus_controller %>-target="jsonInput"
24
+ data-action="input-><%%= stimulus_controller %>#validate"></textarea>
25
+ <div class="generation-settings-error" data-<%%= stimulus_controller %>-target="error" style="display: none;"></div>
24
26
  <div class="generation-settings-hint">
25
27
  Available keys: temperature, top_k, top_p, max_tokens, repeat_penalty
26
28
  </div>
@@ -4,8 +4,6 @@ class CreateChats < ActiveRecord::Migration[8.1]
4
4
  t.references :user, null: true, foreign_key: true
5
5
  t.string :uuid, null: false
6
6
  t.string :title
7
- t.string :llm_uuid
8
- t.string :model
9
7
 
10
8
  t.timestamps
11
9
  end
@@ -0,0 +1,7 @@
1
+ class MigrateLlmUuidToPromptExecutions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :prompt_navigator_prompt_executions, :llm_uuid, :string unless column_exists?(:prompt_navigator_prompt_executions, :llm_uuid)
4
+ remove_column :chats, :llm_uuid, :string if column_exists?(:chats, :llm_uuid)
5
+ remove_column :chats, :model, :string if column_exists?(:chats, :model)
6
+ end
7
+ end
@@ -117,7 +117,7 @@ module LlmMetaClient
117
117
  def ollama_options
118
118
  ollama_list = llms.filter { it["family"] == "ollama" }
119
119
  raise LlmMetaClient::Exceptions::OllamaUnavailableError if ollama_list.empty?
120
- ollama_list
120
+ ollama_list.each { it["llm_type"] ||= "ollama" }
121
121
  end
122
122
 
123
123
  # Builds normalized option hashes from an array of prompts by slicing common keys
@@ -1,3 +1,3 @@
1
1
  module LlmMetaClient
2
- VERSION = "1.0.0"
2
+ VERSION = "1.0.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_meta_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - dhq_boiler
@@ -130,6 +130,7 @@ files:
130
130
  - lib/generators/llm_meta_client/scaffold/templates/config/initializers/llm_service.rb
131
131
  - lib/generators/llm_meta_client/scaffold/templates/db/migrate/create_chats.rb
132
132
  - lib/generators/llm_meta_client/scaffold/templates/db/migrate/create_messages.rb
133
+ - lib/generators/llm_meta_client/scaffold/templates/db/migrate/migrate_llm_uuid_to_prompt_executions.rb
133
134
  - lib/llm_meta_client.rb
134
135
  - lib/llm_meta_client/chat_manageable.rb
135
136
  - lib/llm_meta_client/engine.rb