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
@@ -28,6 +28,11 @@
28
28
  <%%= render partial: "chats/chat_sidebar", locals: { chat: @chat } %>
29
29
  <%% end %>
30
30
 
31
+ <%% # Reveal/refresh the "New Chat" header button now that the chat has content %>
32
+ <%%= turbo_stream.replace "new-chat-button-slot" do %>
33
+ <%%= render partial: "layouts/new_chat_button" %>
34
+ <%% end %>
35
+
31
36
  <%% # Update history sidebar - replace entire content to ensure update %>
32
37
  <%% if @prompt_execution %>
33
38
  <%%= turbo_stream.replace "history-sidebar" do %>
@@ -66,6 +71,16 @@
66
71
  messageInput.focus();
67
72
  }
68
73
 
74
+ // Clear the image-preview box + file input now that the multipart
75
+ // form has been sent. Doing it in the JS submit handler would empty
76
+ // the file input before the browser serialized the POST body.
77
+ const imagePreview = document.querySelector('[data-chats-form-target="imagePreview"]');
78
+ if (imagePreview) imagePreview.style.display = 'none';
79
+ const imageThumbnail = document.querySelector('[data-chats-form-target="imageThumbnail"]');
80
+ if (imageThumbnail) imageThumbnail.src = '';
81
+ const imageInput = document.querySelector('[data-chats-form-target="imageInput"]');
82
+ if (imageInput) imageInput.value = '';
83
+
69
84
  // Update submit button state
70
85
  const form = document.querySelector('[data-controller="chats-form"]');
71
86
  if (form && messageInput) {
@@ -79,6 +94,22 @@
79
94
  branchField.value = '<%%= @prompt_execution&.execution_id %>';
80
95
  }
81
96
 
97
+ // Make this tab's chat identity URL-local so cross-tab navigation
98
+ // can't silently re-target subsequent prompts. After the first POST
99
+ // /chats, swap the URL bar to /chats/<uuid> and point the form at
100
+ // /chats/<uuid>/add_prompt for all subsequent prompts.
101
+ const chatUuid = '<%%= @chat&.uuid %>';
102
+ if (chatUuid) {
103
+ const desiredPath = `/chats/${chatUuid}`;
104
+ if (window.location.pathname !== desiredPath) {
105
+ history.replaceState({}, '', desiredPath);
106
+ }
107
+ const chatForm = document.querySelector('.chat-form');
108
+ if (chatForm) {
109
+ chatForm.action = `/chats/${chatUuid}/add_prompt`;
110
+ }
111
+ }
112
+
82
113
  // Scroll to bottom
83
114
  const chatMessages = document.getElementById('chat-messages');
84
115
  if (chatMessages) {
@@ -0,0 +1,3 @@
1
+ <%%= turbo_stream.replace "chat-sidebar" do %>
2
+ <%%= render partial: "chats/chat_sidebar", locals: { chat: nil } %>
3
+ <%% end %>
@@ -8,30 +8,33 @@
8
8
  </div>
9
9
 
10
10
  <div class="chat-input-container">
11
- <%%= form_with url: chat_path(@chat), method: :patch, class: "chat-form", data: { controller: "chats-form llm-selector", guest: current_user.nil?, action: "submit->chats-form#submit llm-selector:changed->chats-form#updateSubmitButton" } do |f| %>
12
- <%% if @llm_families.present? %>
13
- <div class="llm-selector-wrapper">
14
- <%%= render "shared/family_field", llm_families: @llm_families, stimulus_controller: "llm-selector" %>
15
- <%%= render "shared/api_key_field", stimulus_controller: "llm-selector" %>
16
- <%%= render "shared/model_field", stimulus_controller: "llm-selector" %>
17
- </div>
18
- <%% if user_signed_in? %>
19
- <%%= render "shared/tool_selector_field", stimulus_controller: "tool-selector" %>
20
- <%% end %>
21
- <%%= render "shared/generation_settings_field", stimulus_controller: "generation-settings" %>
22
- <%% end %>
11
+ <%%= form_with url: add_prompt_chat_path(@chat.uuid), method: :post, class: "chat-form", data: { controller: "chats-form model-picker", guest: current_user.nil?, action: "submit->chats-form#submit model-picker:changed->chats-form#updateSubmitButton dragover->chats-form#onDragOver dragleave->chats-form#onDragLeave drop->chats-form#onDrop" } do |f| %>
12
+ <div class="image-preview" data-chats-form-target="imagePreview" style="display: none;">
13
+ <img data-chats-form-target="imageThumbnail" alt="Attached image preview" />
14
+ <button type="button" class="image-clear-button" title="Remove image"
15
+ data-action="click->chats-form#clearImage">
16
+ <i class="bi bi-x-circle"></i>
17
+ </button>
18
+ </div>
23
19
  <div class="input-wrapper">
24
20
  <%%= f.text_area :message,
25
21
  placeholder: "Enter your message...",
26
22
  class: "chat-input",
27
- rows: 3,
23
+ rows: 2,
28
24
  autofocus: true,
29
25
  required: true,
30
26
  id: "message-input",
31
- data: { "chats-form-target": "prompt", action: "input->chats-form#updateSubmitButton" } %>
32
- </div>
33
- <%%= f.hidden_field :branch_from_uuid, value: @branch_from_uuid || params.dig(:chat, :branch_from_uuid) %>
34
- <div class="button-wrapper">
27
+ data: { "chats-form-target": "prompt", action: "input->chats-form#updateSubmitButton keydown->chats-form#onPromptKeydown paste->chats-form#onPaste" } %>
28
+ <button type="button" class="attach-button" disabled
29
+ title="Attach image"
30
+ data-chats-form-target="attachButton"
31
+ data-action="click->chats-form#openImagePicker">
32
+ <i class="bi bi-paperclip"></i>
33
+ </button>
34
+ <%%= f.file_field :image, accept: "image/*",
35
+ data: { "chats-form-target": "imageInput",
36
+ action: "change->chats-form#onImageSelected" },
37
+ style: "display: none;" %>
35
38
  <%%= f.button type: "submit",
36
39
  class: "send-button",
37
40
  disabled: true,
@@ -41,6 +44,39 @@
41
44
  <i class="bi bi-send-fill"></i>
42
45
  <%% end %>
43
46
  </div>
47
+ <%% if @llm_families.present? %>
48
+ <%%= render "shared/quick_picks", llm_families: @llm_families, stimulus_controller: "model-picker" %>
49
+
50
+ <%%# Pre-populate from the chat's most recent execution so the picker
51
+ opens on the model the chat was last using rather than the system
52
+ default. JS treats a non-blank `model` value as the initial pick. %>
53
+ <%%= hidden_field_tag :family, @prompt_execution&.llm_platform.to_s, data: { "model-picker-target": "family" } %>
54
+ <%%= hidden_field_tag :api_key_uuid, @prompt_execution&.llm_uuid.to_s, data: { "model-picker-target": "apiKey" } %>
55
+ <%%= hidden_field_tag :model, @prompt_execution&.model.to_s, data: { "model-picker-target": "model" }, required: true %>
56
+
57
+ <div class="input-controls" data-controller="input-controls">
58
+ <div class="input-controls-buttons">
59
+ <div class="llm-toggle-field" data-controller="llm-toggle">
60
+ <button type="button"
61
+ class="llm-toggle-button"
62
+ data-llm-toggle-target="toggleButton"
63
+ data-action="click->llm-toggle#toggle">
64
+ <i class="bi bi-grid-3x3-gap"></i>
65
+ <span data-llm-toggle-target="label">Other models</span>
66
+ <i class="bi bi-chevron-up toggle-icon" data-llm-toggle-target="toggleIcon"></i>
67
+ </button>
68
+ <div class="llm-toggle-panel" data-llm-toggle-target="panel" style="display: none;">
69
+ <%%= render "shared/model_grid", llm_families: @llm_families, stimulus_controller: "model-picker" %>
70
+ </div>
71
+ </div>
72
+ <%% if user_signed_in? %>
73
+ <%%= render "shared/tool_selector_field", stimulus_controller: "tool-selector" %>
74
+ <%% end %>
75
+ <%%= render "shared/generation_settings_field", stimulus_controller: "generation-settings" %>
76
+ </div>
77
+ </div>
78
+ <%% end %>
79
+ <%%= f.hidden_field :branch_from_uuid, value: @branch_from_uuid || params.dig(:chat, :branch_from_uuid) %>
44
80
  <%% end %>
45
81
  </div>
46
82
  </div>
@@ -8,30 +8,33 @@
8
8
  </div>
9
9
 
10
10
  <div class="chat-input-container">
11
- <%%= form_with url: chats_path, method: :post, class: "chat-form", data: { controller: "chats-form llm-selector", guest: current_user.nil?, action: "submit->chats-form#submit llm-selector:changed->chats-form#updateSubmitButton" } do |f| %>
12
- <%% if @llm_families.present? %>
13
- <div class="llm-selector-wrapper">
14
- <%%= render "shared/family_field", llm_families: @llm_families, stimulus_controller: "llm-selector" %>
15
- <%%= render "shared/api_key_field", stimulus_controller: "llm-selector" %>
16
- <%%= render "shared/model_field", stimulus_controller: "llm-selector" %>
17
- </div>
18
- <%% if user_signed_in? %>
19
- <%%= render "shared/tool_selector_field", stimulus_controller: "tool-selector" %>
20
- <%% end %>
21
- <%%= render "shared/generation_settings_field", stimulus_controller: "generation-settings" %>
22
- <%% end %>
11
+ <%%= form_with url: chats_path, method: :post, class: "chat-form", data: { controller: "chats-form model-picker", guest: current_user.nil?, action: "submit->chats-form#submit model-picker:changed->chats-form#updateSubmitButton dragover->chats-form#onDragOver dragleave->chats-form#onDragLeave drop->chats-form#onDrop" } do |f| %>
12
+ <div class="image-preview" data-chats-form-target="imagePreview" style="display: none;">
13
+ <img data-chats-form-target="imageThumbnail" alt="Attached image preview" />
14
+ <button type="button" class="image-clear-button" title="Remove image"
15
+ data-action="click->chats-form#clearImage">
16
+ <i class="bi bi-x-circle"></i>
17
+ </button>
18
+ </div>
23
19
  <div class="input-wrapper">
24
20
  <%%= f.text_area :message,
25
21
  placeholder: "Enter your message...",
26
22
  class: "chat-input",
27
- rows: 3,
23
+ rows: 2,
28
24
  autofocus: true,
29
25
  required: true,
30
26
  id: "message-input",
31
- data: { "chats-form-target": "prompt", action: "input->chats-form#updateSubmitButton" } %>
32
- </div>
33
- <%%= f.hidden_field :branch_from_uuid, value: @branch_from_uuid || params.dig(:chat, :branch_from_uuid) %>
34
- <div class="button-wrapper">
27
+ data: { "chats-form-target": "prompt", action: "input->chats-form#updateSubmitButton keydown->chats-form#onPromptKeydown paste->chats-form#onPaste" } %>
28
+ <button type="button" class="attach-button" disabled
29
+ title="Attach image"
30
+ data-chats-form-target="attachButton"
31
+ data-action="click->chats-form#openImagePicker">
32
+ <i class="bi bi-paperclip"></i>
33
+ </button>
34
+ <%%= f.file_field :image, accept: "image/*",
35
+ data: { "chats-form-target": "imageInput",
36
+ action: "change->chats-form#onImageSelected" },
37
+ style: "display: none;" %>
35
38
  <%%= f.button type: "submit",
36
39
  class: "send-button",
37
40
  disabled: true,
@@ -41,6 +44,36 @@
41
44
  <i class="bi bi-send-fill"></i>
42
45
  <%% end %>
43
46
  </div>
47
+ <%% if @llm_families.present? %>
48
+ <%%= render "shared/quick_picks", llm_families: @llm_families, stimulus_controller: "model-picker" %>
49
+
50
+ <%%= hidden_field_tag :family, "", data: { "model-picker-target": "family" } %>
51
+ <%%= hidden_field_tag :api_key_uuid, "", data: { "model-picker-target": "apiKey" } %>
52
+ <%%= hidden_field_tag :model, "", data: { "model-picker-target": "model" }, required: true %>
53
+
54
+ <div class="input-controls" data-controller="input-controls">
55
+ <div class="input-controls-buttons">
56
+ <div class="llm-toggle-field" data-controller="llm-toggle">
57
+ <button type="button"
58
+ class="llm-toggle-button"
59
+ data-llm-toggle-target="toggleButton"
60
+ data-action="click->llm-toggle#toggle">
61
+ <i class="bi bi-grid-3x3-gap"></i>
62
+ <span data-llm-toggle-target="label">Other models</span>
63
+ <i class="bi bi-chevron-up toggle-icon" data-llm-toggle-target="toggleIcon"></i>
64
+ </button>
65
+ <div class="llm-toggle-panel" data-llm-toggle-target="panel" style="display: none;">
66
+ <%%= render "shared/model_grid", llm_families: @llm_families, stimulus_controller: "model-picker" %>
67
+ </div>
68
+ </div>
69
+ <%% if user_signed_in? %>
70
+ <%%= render "shared/tool_selector_field", stimulus_controller: "tool-selector" %>
71
+ <%% end %>
72
+ <%%= render "shared/generation_settings_field", stimulus_controller: "generation-settings" %>
73
+ </div>
74
+ </div>
75
+ <%% end %>
76
+ <%%= f.hidden_field :branch_from_uuid, value: @branch_from_uuid || params.dig(:chat, :branch_from_uuid) %>
44
77
  <%% end %>
45
78
  </div>
46
79
  </div>
@@ -4,11 +4,7 @@
4
4
  <%%= link_to "LLM Meta Client", root_path %>
5
5
  </div>
6
6
  <div class="header-right">
7
- <%% if @messages.present? %>
8
- <%%= button_to start_new_chats_path, method: :post, class: "new-text-button" do %>
9
- <i class="fa-solid fa-plus"></i> New Chat
10
- <%% end %>
11
- <%% end %>
7
+ <%%= render partial: "layouts/new_chat_button" %>
12
8
  <%% if user_signed_in? %>
13
9
  <div class="user-menu">
14
10
  <button popovertarget="user-menu-popover" class="user-menu-toggle" type="button">
@@ -0,0 +1,7 @@
1
+ <div id="new-chat-button-slot" class="new-chat-button-slot">
2
+ <%% if @messages.present? || @user_message.present? %>
3
+ <%%= button_to start_new_chats_path, method: :post, class: "new-text-button" do %>
4
+ <i class="fa-solid fa-plus"></i> New Chat
5
+ <%% end %>
6
+ <%% end %>
7
+ </div>
@@ -32,7 +32,7 @@
32
32
  <%% if user_signed_in? %>
33
33
  <div class="sidebar sidebar-left">
34
34
  <div id="chat-sidebar">
35
- <%%= chat_list(->(id) { chat_path(id) }, active_uuid: @active_chat_uuid, download_csv_path: ->(id) { download_csv_chat_path(id) }, download_all_csv_path: download_all_csv_chats_path) %>
35
+ <%%= chat_list(->(id) { chat_path(id) }, active_uuid: @active_chat_uuid, download_csv_path: ->(id) { download_csv_chat_path(id) }, delete_path: ->(id) { chat_path(id) }, batch_delete_path: batch_destroy_chats_path, batch_download_csv_path: download_selected_csv_chats_path) %>
36
36
  </div>
37
37
  </div>
38
38
  <%% end %>
@@ -51,7 +51,7 @@
51
51
  </div>
52
52
  <div class="sidebar sidebar-right">
53
53
  <div id="history-sidebar">
54
- <%%= history_list(->(execution_id) { prompt_path(execution_id) }, active_uuid: @active_message_uuid) %>
54
+ <%%= history_list(->(execution_id) { prompt_path(execution_id) }, active_uuid: @active_message_uuid, delete_path: ->(execution_id) { prompt_path(execution_id) }) %>
55
55
  </div>
56
56
  </div>
57
57
  </div>
@@ -8,8 +8,9 @@
8
8
  data-<%%= stimulus_controller %>-target="toggleButton"
9
9
  data-action="click-><%%= stimulus_controller %>#toggle">
10
10
  <i class="bi bi-sliders"></i>
11
- Generation Settings
12
- <i class="bi bi-chevron-down toggle-icon" data-<%%= stimulus_controller %>-target="toggleIcon"></i>
11
+ Settings
12
+ <span class="tool-count-badge" data-<%%= stimulus_controller %>-target="countBadge" style="display: none;">0</span>
13
+ <i class="bi bi-chevron-up toggle-icon" data-<%%= stimulus_controller %>-target="toggleIcon"></i>
13
14
  </button>
14
15
  </div>
15
16
  <div class="generation-settings-panel" data-<%%= stimulus_controller %>-target="panel" style="display: none;">
@@ -18,13 +19,14 @@
18
19
  </label>
19
20
  <textarea name="generation_settings_json" id="generation_settings_json"
20
21
  class="generation-settings-json-input"
21
- rows="8"
22
- placeholder='{"temperature": 0.7, "top_k": 40, "top_p": 0.9, "max_tokens": 4096, "repeat_penalty": 1.1}'
22
+ rows="3"
23
+ placeholder='{"temperature": 0.7, "max_tokens": 4096, "think": true, "options": {"num_ctx": 8192}}'
23
24
  data-<%%= stimulus_controller %>-target="jsonInput"
24
25
  data-action="input-><%%= stimulus_controller %>#validate"></textarea>
25
26
  <div class="generation-settings-error" data-<%%= stimulus_controller %>-target="error" style="display: none;"></div>
26
27
  <div class="generation-settings-hint">
27
- Available keys: temperature, top_k, top_p, max_tokens, repeat_penalty
28
+ Any JSON keys/values passed through to the provider. Common keys:
29
+ temperature, top_k, top_p, max_tokens, repeat_penalty, think, options.
28
30
  </div>
29
31
  </div>
30
32
  </div>
@@ -0,0 +1,88 @@
1
+ <%%
2
+ llm_families = local_assigns[:llm_families] || []
3
+ stimulus_controller = local_assigns[:stimulus_controller] || "model-picker"
4
+
5
+ # Build one column per (family × api_key) tuple. "Free" (Ollama) is
6
+ # always shown first when available; brand-named columns follow.
7
+ brand_names = { "anthropic" => "Claude", "google" => "Gemini", "openai" => "GPT" }
8
+ ordered_families = [
9
+ llm_families.find { |f| f[:llm_type] == "ollama" }
10
+ ] + brand_names.keys.map { |t| llm_families.find { |f| f[:llm_type] == t } }
11
+
12
+ columns = ordered_families.compact.flat_map do |fam|
13
+ Array(fam[:api_keys]).map do |key|
14
+ base_title = (fam[:llm_type] == "ollama") ? "Free" : brand_names.fetch(fam[:llm_type], fam[:name].to_s)
15
+ # Strip the "[Provider] " prefix that the meta-server adds to the
16
+ # description so the column header reads cleanly.
17
+ desc = key[:description].to_s.sub(/\A\[[^\]]*\]\s*/, "")
18
+ multiple_keys = Array(fam[:api_keys]).size > 1
19
+ title = (multiple_keys && desc.present?) ? "#{base_title} (#{desc})" : base_title
20
+ {
21
+ title: title,
22
+ family: fam[:llm_type],
23
+ api_key_uuid: key[:uuid],
24
+ models: Array(key[:available_models])
25
+ }
26
+ end
27
+ end
28
+ %>
29
+
30
+ <div class="model-grid">
31
+ <%% if columns.any? %>
32
+ <div class="model-grid-columns" style="--cols: <%%= columns.size %>;">
33
+ <%% columns.each do |col| %>
34
+ <div class="model-grid-column">
35
+ <div class="model-grid-header"><%%= col[:title] %></div>
36
+ <ul class="model-grid-list">
37
+ <%% col[:models].each do |m| %>
38
+ <li>
39
+ <button type="button"
40
+ class="model-grid-cell"
41
+ title="<%%= m['value'] %>"
42
+ data-family="<%%= col[:family] %>"
43
+ data-api-key-uuid="<%%= col[:api_key_uuid] %>"
44
+ data-model="<%%= m['value'] %>"
45
+ data-supports-vision="<%%= m['supports_vision'] %>"
46
+ data-action="click-><%%= stimulus_controller %>#pick">
47
+ <span class="model-grid-label"><%%= m['label'] %></span>
48
+ <span class="model-grid-badges">
49
+ <%% if m['kind'].to_s == "image" %>
50
+ <i class="bi bi-image model-badge model-badge-image" title="Image generation"></i>
51
+ <%% end %>
52
+ <%% if m['supports_vision'] %>
53
+ <i class="bi bi-eye model-badge model-badge-vision" title="Vision input"></i>
54
+ <%% end %>
55
+ <%% if m['supports_tools'] %>
56
+ <i class="bi bi-tools model-badge model-badge-tools" title="Tool / function calling"></i>
57
+ <%% end %>
58
+ <%% if m['favorite'] %>
59
+ <i class="bi bi-star-fill model-badge model-badge-favorite" title="One of your favorites"></i>
60
+ <%% end %>
61
+ <%% if m['default'] %>
62
+ <i class="bi bi-bookmark-star-fill model-badge model-badge-default" title="Your default model"></i>
63
+ <%% end %>
64
+ </span>
65
+ </button>
66
+ </li>
67
+ <%% end %>
68
+ </ul>
69
+ </div>
70
+ <%% end %>
71
+ </div>
72
+ <%% else %>
73
+ <p class="model-grid-empty">No models available.</p>
74
+ <%% end %>
75
+
76
+ <p class="model-grid-footer">
77
+ <i class="bi bi-info-circle"></i>
78
+ <%% if user_signed_in? %>
79
+ Access
80
+ <%%= link_to "hub.AIbranch",
81
+ Rails.configuration.llm_service_public_url,
82
+ target: "_blank", rel: "noopener" %>
83
+ to unlock more models by registering your API keys.
84
+ <%% else %>
85
+ Sign in to add API keys, favorites, and set a personal default model.
86
+ <%% end %>
87
+ </p>
88
+ </div>
@@ -0,0 +1,67 @@
1
+ <%%
2
+ llm_families = local_assigns[:llm_families] || []
3
+ stimulus_controller = local_assigns[:stimulus_controller] || "model-picker"
4
+
5
+ # Flatten every (family × api_key × model) combination so the picker
6
+ # can find both the user's default and their favorites in one pass.
7
+ all_options = llm_families.flat_map do |fam|
8
+ Array(fam[:api_keys]).flat_map do |key|
9
+ Array(key[:available_models]).map do |m|
10
+ {
11
+ family: fam[:llm_type],
12
+ api_key_uuid: key[:uuid],
13
+ meta_id: m["value"],
14
+ label: m["label"],
15
+ favorite: m["favorite"] == true,
16
+ default: m["default"] == true,
17
+ supports_vision: m["supports_vision"] == true,
18
+ kind: m["kind"]
19
+ }
20
+ end
21
+ end
22
+ end
23
+
24
+ # The user's per-user default (set on the meta-server's /models page) wins.
25
+ # If they haven't set one, fall back to the system-wide default from config.
26
+ system_default_meta = Rails.configuration.default_model
27
+ default_option = all_options.find { |o| o[:default] } ||
28
+ all_options.find { |o| o[:meta_id] == system_default_meta }
29
+
30
+ # Favorites that aren't already the default (to avoid showing the same
31
+ # model twice in the row). Anonymous visitors have no favorites — they
32
+ # only see the free (Ollama) family — so promote every available model
33
+ # to a quick-pick for them, since picking from the grid would be the
34
+ # only other option.
35
+ favorite_options = all_options.reject { |o| o[:meta_id] == default_option&.dig(:meta_id) }
36
+ .select { |o| user_signed_in? ? o[:favorite] : true }
37
+ %>
38
+
39
+ <%%# Always render the container so model-picker can append a transient
40
+ quick-pick when the user picks an off-row model from the grid. %>
41
+ <div class="quick-picks" data-<%%= stimulus_controller %>-target="quickPicks">
42
+ <%% if default_option %>
43
+ <button type="button"
44
+ class="quick-pick-button is-default"
45
+ title="Your default model"
46
+ data-family="<%%= default_option[:family] %>"
47
+ data-api-key-uuid="<%%= default_option[:api_key_uuid] %>"
48
+ data-model="<%%= default_option[:meta_id] %>"
49
+ data-supports-vision="<%%= default_option[:supports_vision] %>"
50
+ data-action="click-><%%= stimulus_controller %>#pick">
51
+ <%%= default_option[:label] %>
52
+ </button>
53
+ <%% end %>
54
+
55
+ <%% favorite_options.each do |o| %>
56
+ <button type="button"
57
+ class="quick-pick-button"
58
+ title="<%%= o[:meta_id] %>"
59
+ data-family="<%%= o[:family] %>"
60
+ data-api-key-uuid="<%%= o[:api_key_uuid] %>"
61
+ data-model="<%%= o[:meta_id] %>"
62
+ data-supports-vision="<%%= o[:supports_vision] %>"
63
+ data-action="click-><%%= stimulus_controller %>#pick">
64
+ <%%= o[:label] %>
65
+ </button>
66
+ <%% end %>
67
+ </div>
@@ -10,7 +10,7 @@
10
10
  <i class="bi bi-tools"></i>
11
11
  Tools
12
12
  <span class="tool-count-badge" data-<%%= stimulus_controller %>-target="countBadge" style="display: none;">0</span>
13
- <i class="bi bi-chevron-down toggle-icon" data-<%%= stimulus_controller %>-target="toggleIcon"></i>
13
+ <i class="bi bi-chevron-up toggle-icon" data-<%%= stimulus_controller %>-target="toggleIcon"></i>
14
14
  </button>
15
15
  </div>
16
16
  <div class="tool-selector-panel" data-<%%= stimulus_controller %>-target="panel" style="display: none;">
@@ -2,5 +2,23 @@ module LlmMetaClient
2
2
  module Helpers
3
3
  include PromptNavigator::Helpers
4
4
  include ChatManager::Helpers
5
+
6
+ # Pull a leading `![](data:mime;base64,DATA)` image off a user prompt
7
+ # so it can be rendered as a plain <img> while the remaining text stays
8
+ # plain. Returns [img_html_safe_or_nil, remaining_text]. Emits the tag
9
+ # directly (no markdown helper required) so this works in any host.
10
+ ATTACHED_IMAGE_HEAD = /\A!\[[^\]]*\]\(data:([^;]+);base64,([^\)]+)\)\s*\n*/m
11
+
12
+ def split_attached_image_html(text)
13
+ s = text.to_s
14
+ m = s.match(ATTACHED_IMAGE_HEAD)
15
+ return [ nil, s ] unless m
16
+ img = tag.img(
17
+ src: "data:#{m[1]};base64,#{m[2]}",
18
+ alt: "",
19
+ class: "user-attached-image"
20
+ )
21
+ [ img, s.sub(ATTACHED_IMAGE_HEAD, "") ]
22
+ end
5
23
  end
6
24
  end
@@ -12,13 +12,25 @@ module LlmMetaClient
12
12
  # Returns the final assistant content. If tool calls fired, the returned
13
13
  # string mirrors the synchronous #call format (response + markdown
14
14
  # "Tool calls" section appended) so persistence stays consistent.
15
- def stream(id_token, api_key_uuid, model_id, context, user_content, tool_ids: [], generation_settings: {})
16
- context_and_user_content = "Context:#{context}, User Prompt: #{user_content}"
17
- debug_log "Streaming request to LLM: \n===>\n#{context_and_user_content}\n===>"
18
-
19
- body = { prompt: context_and_user_content }
15
+ def stream(id_token, api_key_uuid, model_id, context, user_content, tool_ids: [], generation_settings: {}, image_context: nil, image: nil, images: nil)
16
+ if image_context.present?
17
+ prompt_text = user_content.is_a?(Hash) ? (user_content[:prompt] || user_content["prompt"]).to_s : user_content.to_s
18
+ debug_log "Streaming image request to LLM: \n===>\n#{prompt_text}\n(with #{image_context.size} prior turn(s))\n===>"
19
+ body = { prompt: prompt_text, image_context: image_context }
20
+ else
21
+ context_and_user_content = "Context:#{context}, User Prompt: #{user_content}"
22
+ debug_log "Streaming request to LLM: \n===>\n#{context_and_user_content}\n===>"
23
+ body = { prompt: context_and_user_content }
24
+ end
20
25
  body[:tool_ids] = tool_ids if tool_ids.present?
21
26
  body[:generation_settings] = generation_settings if generation_settings.present?
27
+ # images: ordered chronologically with the current turn's image last.
28
+ # Legacy single `image:` is forwarded as a fallback for older callers.
29
+ if images.present?
30
+ body[:images] = images
31
+ elsif image.present?
32
+ body[:image] = image
33
+ end
22
34
 
23
35
  assembled = +""
24
36
  collected_tool_calls = []
@@ -30,6 +42,11 @@ module LlmMetaClient
30
42
  when "tool_calls"
31
43
  collected_tool_calls = event[:data]["tool_calls"] || []
32
44
  yield event if block_given?
45
+ when "thinking"
46
+ # Thinking-mode deltas (Ollama hybrid models): forwarded to the
47
+ # caller for live rendering, but NOT folded into `assembled` —
48
+ # only the final content is persisted as the assistant message.
49
+ yield event if block_given?
33
50
  when "done"
34
51
  # End-of-stream marker from upstream; no-op here.
35
52
  when "error"
@@ -138,7 +155,7 @@ module LlmMetaClient
138
155
 
139
156
  buffer = +""
140
157
  response.read_body do |chunk|
141
- buffer << chunk
158
+ buffer << chunk.force_encoding("UTF-8")
142
159
  while (boundary = buffer.index("\n\n"))
143
160
  raw_event = buffer.slice!(0, boundary + 2)
144
161
  parsed = parse_sse_event(raw_event)
@@ -173,6 +190,7 @@ module LlmMetaClient
173
190
  if body.is_a?(Hash)
174
191
  err = body["error"]
175
192
  msg = body["message"]
193
+ return "Your sign-in expired. Please sign in again." if err.to_s.match?(/token has expired/i)
176
194
  return "#{err}: #{msg}" if err.present? && msg.present?
177
195
  return err if err.present?
178
196
  return msg if msg.present?
@@ -1,3 +1,3 @@
1
1
  module LlmMetaClient
2
- VERSION = "1.4.0"
2
+ VERSION = "1.5.0"
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.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dhq_boiler
@@ -112,11 +112,16 @@ files:
112
112
  - lib/generators/llm_meta_client/scaffold/templates/app/controllers/chat_streams_controller.rb
113
113
  - lib/generators/llm_meta_client/scaffold/templates/app/controllers/chats_controller.rb
114
114
  - lib/generators/llm_meta_client/scaffold/templates/app/controllers/prompts_controller.rb
115
+ - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/asset_actions_controller.js
116
+ - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_controller.js
117
+ - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_menu_controller.js
115
118
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chat_title_edit_controller.js
116
119
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/chats_form_controller.js
117
120
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/generation_settings_controller.js
118
- - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/llm_selector_controller.js
121
+ - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/input_controls_controller.js
122
+ - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/llm_toggle_controller.js
119
123
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/message_stream_controller.js
124
+ - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/model_picker_controller.js
120
125
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/controllers/tool_selector_controller.js
121
126
  - lib/generators/llm_meta_client/scaffold/templates/app/javascript/popover.js
122
127
  - lib/generators/llm_meta_client/scaffold/templates/app/models/chat.rb
@@ -127,16 +132,16 @@ files:
127
132
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_streaming_message.html.erb
128
133
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/_tool_call_message.html.erb
129
134
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/create.turbo_stream.erb
135
+ - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/destroy.turbo_stream.erb
130
136
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/edit.html.erb
131
137
  - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/new.html.erb
132
- - lib/generators/llm_meta_client/scaffold/templates/app/views/chats/update.turbo_stream.erb
133
138
  - lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_header.html.erb
139
+ - lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_new_chat_button.html.erb
134
140
  - lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_sidebar.html.erb
135
141
  - lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/application.html.erb
136
- - lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_api_key_field.html.erb
137
- - lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_family_field.html.erb
138
142
  - lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_generation_settings_field.html.erb
139
- - lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_model_field.html.erb
143
+ - lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_model_grid.html.erb
144
+ - lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_quick_picks.html.erb
140
145
  - lib/generators/llm_meta_client/scaffold/templates/app/views/shared/_tool_selector_field.html.erb
141
146
  - lib/generators/llm_meta_client/scaffold/templates/config/initializers/llm_service.rb
142
147
  - lib/generators/llm_meta_client/scaffold/templates/db/migrate/create_chats.rb