llm_meta_client 1.4.0 → 1.6.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 +33 -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 +31 -9
- 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/views/chats/create.turbo_stream.erb
CHANGED
|
@@ -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) {
|
|
@@ -8,30 +8,33 @@
|
|
|
8
8
|
</div>
|
|
9
9
|
|
|
10
10
|
<div class="chat-input-container">
|
|
11
|
-
<%%= form_with url:
|
|
12
|
-
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
</
|
|
18
|
-
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
</
|
|
18
|
-
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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">
|
data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/_new_chat_button.html.erb
ADDED
|
@@ -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>
|
data/lib/generators/llm_meta_client/scaffold/templates/app/views/layouts/application.html.erb
CHANGED
|
@@ -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) },
|
|
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
|
-
|
|
12
|
-
<
|
|
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="
|
|
22
|
-
placeholder='{"temperature": 0.7, "
|
|
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
|
-
|
|
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-
|
|
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 `` 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,28 @@ 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
def stream(id_token, api_key_uuid, model_id, context, user_content, tool_ids: [], generation_settings: {}, image_context: nil, image: nil, images: nil, document: 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
|
|
34
|
+
# document: single-attachment PDF for the current turn. Server enforces
|
|
35
|
+
# MIME (application/pdf) and a 10 MB cap; we just forward the payload.
|
|
36
|
+
body[:document] = document if document.present?
|
|
22
37
|
|
|
23
38
|
assembled = +""
|
|
24
39
|
collected_tool_calls = []
|
|
@@ -30,6 +45,11 @@ module LlmMetaClient
|
|
|
30
45
|
when "tool_calls"
|
|
31
46
|
collected_tool_calls = event[:data]["tool_calls"] || []
|
|
32
47
|
yield event if block_given?
|
|
48
|
+
when "thinking"
|
|
49
|
+
# Thinking-mode deltas (Ollama hybrid models): forwarded to the
|
|
50
|
+
# caller for live rendering, but NOT folded into `assembled` —
|
|
51
|
+
# only the final content is persisted as the assistant message.
|
|
52
|
+
yield event if block_given?
|
|
33
53
|
when "done"
|
|
34
54
|
# End-of-stream marker from upstream; no-op here.
|
|
35
55
|
when "error"
|
|
@@ -42,12 +62,12 @@ module LlmMetaClient
|
|
|
42
62
|
collected_tool_calls.any? ? combine_with_tool_calls(assembled, collected_tool_calls) : assembled
|
|
43
63
|
end
|
|
44
64
|
|
|
45
|
-
def call(id_token, api_key_uuid, model_id, context, user_content, tool_ids: [], generation_settings: {})
|
|
65
|
+
def call(id_token, api_key_uuid, model_id, context, user_content, tool_ids: [], generation_settings: {}, document: nil)
|
|
46
66
|
debug_log "Context: #{context}"
|
|
47
67
|
context_and_user_content = "Context:#{context}, User Prompt: #{user_content}"
|
|
48
68
|
debug_log "Request to LLM: \n===>\n#{context_and_user_content}\n===>"
|
|
49
69
|
|
|
50
|
-
response = request(api_key_uuid, id_token, model_id, context_and_user_content, tool_ids, generation_settings)
|
|
70
|
+
response = request(api_key_uuid, id_token, model_id, context_and_user_content, tool_ids, generation_settings, document: document)
|
|
51
71
|
|
|
52
72
|
unless response.success?
|
|
53
73
|
raise Exceptions::ServerError, build_error_message(response.code.to_i, response.parsed_response)
|
|
@@ -96,13 +116,14 @@ module LlmMetaClient
|
|
|
96
116
|
lines.join("\n")
|
|
97
117
|
end
|
|
98
118
|
|
|
99
|
-
def request(api_key_uuid, id_token, model_id, user_content, tool_ids, generation_settings)
|
|
119
|
+
def request(api_key_uuid, id_token, model_id, user_content, tool_ids, generation_settings, document: nil)
|
|
100
120
|
headers = { "Content-Type" => "application/json" }
|
|
101
121
|
headers["Authorization"] = "Bearer #{id_token}" if id_token.present?
|
|
102
122
|
|
|
103
123
|
body = { prompt: user_content.to_s }
|
|
104
124
|
body[:tool_ids] = tool_ids if tool_ids.present?
|
|
105
125
|
body[:generation_settings] = generation_settings if generation_settings.present?
|
|
126
|
+
body[:document] = document if document.present?
|
|
106
127
|
|
|
107
128
|
HTTParty.post(
|
|
108
129
|
url(api_key_uuid, model_id),
|
|
@@ -138,7 +159,7 @@ module LlmMetaClient
|
|
|
138
159
|
|
|
139
160
|
buffer = +""
|
|
140
161
|
response.read_body do |chunk|
|
|
141
|
-
buffer << chunk
|
|
162
|
+
buffer << chunk.force_encoding("UTF-8")
|
|
142
163
|
while (boundary = buffer.index("\n\n"))
|
|
143
164
|
raw_event = buffer.slice!(0, boundary + 2)
|
|
144
165
|
parsed = parse_sse_event(raw_event)
|
|
@@ -173,6 +194,7 @@ module LlmMetaClient
|
|
|
173
194
|
if body.is_a?(Hash)
|
|
174
195
|
err = body["error"]
|
|
175
196
|
msg = body["message"]
|
|
197
|
+
return "Your sign-in expired. Please sign in again." if err.to_s.match?(/token has expired/i)
|
|
176
198
|
return "#{err}: #{msg}" if err.present? && msg.present?
|
|
177
199
|
return err if err.present?
|
|
178
200
|
return msg if msg.present?
|