llm_meta_client 1.3.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 +38 -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 +128 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 392fd4f6a93475e739beef49bf50b8158fced469993b78875a22845777bcf96b
|
|
4
|
+
data.tar.gz: '090a33919e5ac2916b1d5af16b11e9e831f954c00dc1a61f1459db931f3f02f8'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e9546f73d0e30b8ebc5074fdbb5a244f0e657d82094e7ba64383d9aad07c69cfc2570cb6c0c7ec316f4314ab0c693a7260cf25e79301e2d1a633d93e47a97fb
|
|
7
|
+
data.tar.gz: 2f84dd1aa7c422081d30a7c02b963eeae90c4e3b43e0b957f575e8cd348a773189f1137bb979f6922420404f7e7d3acbf70abe5403eabaa736e4a3cd14feb5be
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ 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.5.0] - 2026-06-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Thinking-mode SSE event: `message_stream_controller` handles `event: thinking` for reasoning models and renders a fixed-height collapsible reasoning panel with pulsing dots while the model is mid-thought.
|
|
13
|
+
- Image input — `ServerQuery#stream` and `#run` now accept an `images:` array; scaffold templates add Enter-to-send, image preview, drag-and-drop, and a URL-based chat identity so reload preserves the active conversation.
|
|
14
|
+
- Image-generation plumbing: scaffold supports Gemini image-gen responses (data-URI rendering, save-as-PNG) and a cancel button that aborts an in-flight stream.
|
|
15
|
+
- Asset actions: scaffold ships `asset_actions_controller` so assistant responses get Download / Copy buttons on JSON, CSV, and image blocks.
|
|
16
|
+
- Grid-view model picker — quick-picks row + searchable "Other models" tile grid with a Settings count badge; favorites can be reordered.
|
|
17
|
+
- Default-LLM persistence, batch chat actions (multi-select destroy/export), refreshable "New Chat" button, and a redesigned chat input.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `message_stream_controller` UI polish: fixed-height reasoning area, pulsing dots, smoother streaming bubble layout.
|
|
22
|
+
- Scaffold model-selection labels renamed for consistency with the hub UI.
|
|
23
|
+
- `strip_title_markdown` helper normalizes generated chat titles for sidebar display.
|
|
24
|
+
|
|
25
|
+
### Tested
|
|
26
|
+
|
|
27
|
+
- `ServerQuery` HTTP client — sync, SSE, and error-mapping branches.
|
|
28
|
+
- `ServerResource` REST helpers + Ollama-fallback branches.
|
|
29
|
+
- `Helpers#split_attached_image_html` regex.
|
|
30
|
+
|
|
31
|
+
### Notes
|
|
32
|
+
|
|
33
|
+
- Compatible with `llm_meta_server` ≥ the matching server-side thinking-event and image-input plumbing.
|
|
34
|
+
|
|
35
|
+
## [1.4.0] - 2026-05-11
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Phase indicator in the streaming bubble. `message_stream_controller` now handles `event: phase` from the server and updates the role label (e.g. "🤔 Thinking…") so the user sees progress during turn 1 tool selection and turn 2 reasoning. The label auto-flips back to "🤖 streaming…" on the first content delta.
|
|
40
|
+
- Streaming endpoint emits SSE keepalive comment lines during long synchronous waits (works alongside the server-side heartbeat thread on `llm_meta_server`). EventSource ignores these by design; they keep the connection warm through buffering proxies.
|
|
41
|
+
|
|
42
|
+
### Notes
|
|
43
|
+
|
|
44
|
+
- Requires `llm_meta_server` with the matching `on_phase_change` plumbing in `LlmRbFacade.stream!` and the heartbeat thread in `Api::ChatStreamsController`.
|
|
45
|
+
|
|
8
46
|
## [1.3.0] - 2026-05-10
|
|
9
47
|
|
|
10
48
|
### Added
|
|
@@ -29,28 +29,31 @@ module LlmMetaClient
|
|
|
29
29
|
template "app/views/chats/edit.html.erb"
|
|
30
30
|
template "app/views/chats/create.turbo_stream.erb"
|
|
31
31
|
template "app/views/chats/update.turbo_stream.erb"
|
|
32
|
+
template "app/views/chats/destroy.turbo_stream.erb"
|
|
32
33
|
template "app/views/chats/_message.html.erb"
|
|
33
34
|
template "app/views/chats/_streaming_message.html.erb"
|
|
34
35
|
template "app/views/chats/_tool_call_message.html.erb"
|
|
35
36
|
template "app/views/chats/_chat_sidebar.html.erb"
|
|
36
37
|
template "app/views/chats/_messages_list.html.erb"
|
|
37
|
-
template "app/views/shared/
|
|
38
|
-
template "app/views/shared/
|
|
39
|
-
template "app/views/shared/_model_field.html.erb"
|
|
38
|
+
template "app/views/shared/_quick_picks.html.erb"
|
|
39
|
+
template "app/views/shared/_model_grid.html.erb"
|
|
40
40
|
template "app/views/shared/_tool_selector_field.html.erb"
|
|
41
41
|
template "app/views/shared/_generation_settings_field.html.erb"
|
|
42
42
|
template "app/views/layouts/application.html.erb"
|
|
43
43
|
template "app/views/layouts/_header.html.erb"
|
|
44
|
+
template "app/views/layouts/_new_chat_button.html.erb"
|
|
44
45
|
template "app/views/layouts/_sidebar.html.erb"
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def create_javascript
|
|
48
|
-
template "app/javascript/controllers/
|
|
49
|
+
template "app/javascript/controllers/model_picker_controller.js"
|
|
50
|
+
template "app/javascript/controllers/llm_toggle_controller.js"
|
|
49
51
|
template "app/javascript/controllers/chats_form_controller.js"
|
|
50
52
|
template "app/javascript/controllers/chat_title_edit_controller.js"
|
|
51
53
|
template "app/javascript/controllers/tool_selector_controller.js"
|
|
52
54
|
template "app/javascript/controllers/generation_settings_controller.js"
|
|
53
55
|
template "app/javascript/controllers/message_stream_controller.js"
|
|
56
|
+
template "app/javascript/controllers/asset_actions_controller.js"
|
|
54
57
|
copy_file "app/javascript/popover.js"
|
|
55
58
|
end
|
|
56
59
|
|
|
@@ -68,19 +71,21 @@ module LlmMetaClient
|
|
|
68
71
|
route <<-RUBY
|
|
69
72
|
root "chats#new"
|
|
70
73
|
|
|
71
|
-
resources :chats, only: [ :new, :create, :
|
|
74
|
+
resources :chats, only: [ :new, :create, :show, :destroy ] do
|
|
72
75
|
collection do
|
|
73
76
|
delete :clear
|
|
74
77
|
post :start_new
|
|
75
|
-
|
|
78
|
+
delete :batch_destroy
|
|
79
|
+
post :download_selected_csv
|
|
76
80
|
end
|
|
77
81
|
member do
|
|
78
82
|
patch :update_title
|
|
79
83
|
get :download_csv
|
|
84
|
+
post :add_prompt
|
|
80
85
|
end
|
|
81
86
|
resource :stream, only: [ :show ], controller: "chat_streams"
|
|
82
87
|
end
|
|
83
|
-
resources :prompts, only: [ :show ]
|
|
88
|
+
resources :prompts, only: [ :show, :destroy ]
|
|
84
89
|
|
|
85
90
|
namespace :api do
|
|
86
91
|
resources :mcp_servers, only: [ :index ], param: :uuid do
|
data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/api/mcp_servers_controller.rb
CHANGED
|
@@ -5,13 +5,13 @@ class Api::McpServersController < ApplicationController
|
|
|
5
5
|
before_action :authenticate_user!
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
|
-
jwt_token = current_user.
|
|
8
|
+
jwt_token = current_user.jwt_token
|
|
9
9
|
mcp_servers = LlmMetaClient::ServerResource.fetch_mcp_servers(jwt_token)
|
|
10
10
|
render json: { mcp_servers: mcp_servers }
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def tools
|
|
14
|
-
jwt_token = current_user.
|
|
14
|
+
jwt_token = current_user.jwt_token
|
|
15
15
|
tools = LlmMetaClient::ServerResource.fetch_mcp_tools(jwt_token, params[:uuid])
|
|
16
16
|
render json: { tools: tools }
|
|
17
17
|
end
|
data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/chat_streams_controller.rb
CHANGED
|
@@ -9,17 +9,28 @@ class ChatStreamsController < ApplicationController
|
|
|
9
9
|
response.headers["Cache-Control"] = "no-cache"
|
|
10
10
|
response.headers["X-Accel-Buffering"] = "no"
|
|
11
11
|
|
|
12
|
+
# Initialize early so the disconnect rescue can persist whatever was
|
|
13
|
+
# forwarded before the user clicked Cancel.
|
|
14
|
+
partial = +""
|
|
15
|
+
chat = nil
|
|
16
|
+
prompt_execution = nil
|
|
17
|
+
jwt_token = nil
|
|
18
|
+
|
|
12
19
|
chat = find_chat
|
|
13
20
|
prompt_execution = PromptNavigator::PromptExecution.find_by!(execution_id: params[:execution_id])
|
|
14
21
|
unless chat.messages.exists?(prompt_navigator_prompt_execution_id: prompt_execution.id)
|
|
15
22
|
raise ActiveRecord::RecordNotFound
|
|
16
23
|
end
|
|
17
24
|
|
|
18
|
-
jwt_token = current_user.
|
|
25
|
+
jwt_token = current_user.jwt_token if user_signed_in?
|
|
19
26
|
generation_settings = parse_generation_settings(params[:generation_settings_json])
|
|
20
27
|
tool_ids = Array(params[:tool_ids]).reject(&:blank?)
|
|
21
28
|
|
|
22
29
|
assembled = chat.stream_assistant_response(prompt_execution, jwt_token, tool_ids: tool_ids, generation_settings: generation_settings) do |event|
|
|
30
|
+
# Accumulate before forwarding so a write failure (ClientDisconnected)
|
|
31
|
+
# doesn't drop the delta we just received.
|
|
32
|
+
partial << event[:data]["delta"].to_s if event[:event] == "message"
|
|
33
|
+
|
|
23
34
|
if event[:event] == "tool_calls"
|
|
24
35
|
tool_calls = event[:data]["tool_calls"] || []
|
|
25
36
|
forward(event: "tool_calls", data: {
|
|
@@ -54,7 +65,8 @@ class ChatStreamsController < ApplicationController
|
|
|
54
65
|
|
|
55
66
|
forward(event: "done", data: {})
|
|
56
67
|
rescue ActionController::Live::ClientDisconnected
|
|
57
|
-
Rails.logger.info "[ChatStream] client disconnected"
|
|
68
|
+
Rails.logger.info "[ChatStream] client disconnected (partial=#{partial.length} chars)"
|
|
69
|
+
persist_partial(chat, prompt_execution, partial, jwt_token)
|
|
58
70
|
rescue ActiveRecord::RecordNotFound
|
|
59
71
|
forward(event: "error", data: { code: "not_found", message: "Chat or prompt execution not found" }) rescue nil
|
|
60
72
|
rescue StandardError => e
|
|
@@ -66,6 +78,16 @@ class ChatStreamsController < ApplicationController
|
|
|
66
78
|
|
|
67
79
|
private
|
|
68
80
|
|
|
81
|
+
# Best-effort save of the partial content the user already saw when they
|
|
82
|
+
# cancelled mid-stream. Skips if there's nothing to save or required
|
|
83
|
+
# context wasn't established before the disconnect.
|
|
84
|
+
def persist_partial(chat, prompt_execution, partial, jwt_token)
|
|
85
|
+
return unless chat && prompt_execution && partial.present?
|
|
86
|
+
chat.finalize_streamed_response(prompt_execution, partial, jwt_token)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Rails.logger.warn "[ChatStream] persist_partial failed: #{e.class}: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
69
91
|
def find_chat
|
|
70
92
|
scope = user_signed_in? ? current_user.chats : Chat.where(user_id: nil)
|
|
71
93
|
scope.find_by!(uuid: params[:chat_id])
|
|
@@ -4,21 +4,21 @@ class ChatsController < ApplicationController
|
|
|
4
4
|
include PromptNavigator::HistoryManageable
|
|
5
5
|
# Allow access without login
|
|
6
6
|
skip_before_action :authenticate_user!, raise: false
|
|
7
|
-
before_action :authenticate_user!, only: [ :update_title, :download_csv, :
|
|
7
|
+
before_action :authenticate_user!, only: [ :update_title, :download_csv, :download_selected_csv, :batch_destroy ]
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
10
|
# Initialize chat context
|
|
11
11
|
initialize_chat current_user&.chats
|
|
12
12
|
|
|
13
13
|
@chat = current_user&.chats.includes(:messages).find_by!(uuid: params[:id])
|
|
14
|
-
|
|
14
|
+
set_active_chat_uuid(@chat&.uuid)
|
|
15
15
|
@messages = @chat.ordered_messages
|
|
16
16
|
|
|
17
17
|
# Initialize history
|
|
18
18
|
initialize_history @chat.ordered_by_descending_prompt_executions
|
|
19
19
|
|
|
20
20
|
# Get LLM options available for users
|
|
21
|
-
jwt_token = current_user.
|
|
21
|
+
jwt_token = current_user.jwt_token if user_signed_in?
|
|
22
22
|
@llm_families = LlmMetaClient::ServerResource.available_llm_families(jwt_token)
|
|
23
23
|
|
|
24
24
|
# Set active UUID for history sidebar highlighting
|
|
@@ -32,19 +32,15 @@ class ChatsController < ApplicationController
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def new
|
|
35
|
+
# Pure new-chat form. No chat row is created until the user actually
|
|
36
|
+
# submits, and nothing is read from session — so opening "/" in a second
|
|
37
|
+
# tab can never surface a previously-active chat from another tab.
|
|
35
38
|
initialize_chat current_user&.chats
|
|
36
|
-
|
|
37
|
-
@
|
|
38
|
-
|
|
39
|
-
current_user
|
|
40
|
-
)
|
|
41
|
-
add_chat @chat
|
|
42
|
-
@messages = @chat&.ordered_messages || []
|
|
43
|
-
# initialize history for the chat
|
|
44
|
-
initialize_history @chat&.ordered_by_descending_prompt_executions
|
|
39
|
+
@chat = nil
|
|
40
|
+
@messages = []
|
|
41
|
+
initialize_history []
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
jwt_token = current_user.id_token if user_signed_in?
|
|
43
|
+
jwt_token = current_user.jwt_token if user_signed_in?
|
|
48
44
|
@llm_families = LlmMetaClient::ServerResource.available_llm_families(jwt_token)
|
|
49
45
|
rescue StandardError => e
|
|
50
46
|
Rails.logger.error "Error in ChatsController#new: #{e.class} - #{e.message}\n#{e.backtrace&.join("\n")}"
|
|
@@ -53,17 +49,17 @@ class ChatsController < ApplicationController
|
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
def create
|
|
56
|
-
jwt_token = current_user.
|
|
52
|
+
jwt_token = current_user.jwt_token if user_signed_in?
|
|
57
53
|
|
|
58
54
|
# Initialize chat sidebar
|
|
59
55
|
initialize_chat current_user&.chats
|
|
60
56
|
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
57
|
+
# Always create a new chat — URL/form is the source of truth for chat
|
|
58
|
+
# identity, not session[:chat_id]. This makes the entry point tab-safe:
|
|
59
|
+
# cross-tab navigation can no longer rewrite the "current chat" under us.
|
|
60
|
+
@chat = Chat.create!(user: current_user)
|
|
66
61
|
add_chat @chat
|
|
62
|
+
set_active_chat_uuid(@chat&.uuid)
|
|
67
63
|
@messages = @chat&.ordered_messages || []
|
|
68
64
|
|
|
69
65
|
# initialize history for the chat
|
|
@@ -87,7 +83,9 @@ class ChatsController < ApplicationController
|
|
|
87
83
|
@prompt_execution, @user_message = @chat.add_user_message(params[:message],
|
|
88
84
|
params[:api_key_uuid],
|
|
89
85
|
params[:model],
|
|
90
|
-
params[:branch_from_uuid]
|
|
86
|
+
params[:branch_from_uuid],
|
|
87
|
+
llm_platform: params[:family],
|
|
88
|
+
image: uploaded_image_payload)
|
|
91
89
|
# Push to history for rendering
|
|
92
90
|
push_to_history @prompt_execution
|
|
93
91
|
# Set active message UUID for highlighting in UI
|
|
@@ -107,6 +105,33 @@ class ChatsController < ApplicationController
|
|
|
107
105
|
end
|
|
108
106
|
end
|
|
109
107
|
|
|
108
|
+
def destroy
|
|
109
|
+
scope = user_signed_in? ? current_user.chats : Chat.where(user_id: nil)
|
|
110
|
+
chat = scope.find_by(uuid: params[:id])
|
|
111
|
+
# "Currently viewing this chat" is now identified by the URL the user
|
|
112
|
+
# came from (referrer), since chat identity is URL-local, not session.
|
|
113
|
+
was_viewed = chat && request.referer.to_s.include?("/chats/#{chat.uuid}")
|
|
114
|
+
chat&.destroy
|
|
115
|
+
|
|
116
|
+
initialize_chat(user_signed_in? ? current_user.chats : nil)
|
|
117
|
+
|
|
118
|
+
if was_viewed
|
|
119
|
+
redirect_to root_path
|
|
120
|
+
else
|
|
121
|
+
respond_to do |format|
|
|
122
|
+
format.turbo_stream
|
|
123
|
+
format.html { redirect_to root_path }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def batch_destroy
|
|
129
|
+
scope = user_signed_in? ? current_user.chats : Chat.where(user_id: nil)
|
|
130
|
+
uuids = Array(params[:uuids]).reject(&:blank?)
|
|
131
|
+
scope.where(uuid: uuids).destroy_all
|
|
132
|
+
redirect_to root_path
|
|
133
|
+
end
|
|
134
|
+
|
|
110
135
|
def update_title
|
|
111
136
|
chat = current_user.chats.find_by!(uuid: params[:id])
|
|
112
137
|
title = params[:title].to_s.strip
|
|
@@ -126,102 +151,93 @@ class ChatsController < ApplicationController
|
|
|
126
151
|
end
|
|
127
152
|
|
|
128
153
|
def start_new
|
|
129
|
-
session.delete(:chat_id)
|
|
130
154
|
redirect_to root_path
|
|
131
155
|
end
|
|
132
156
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
jwt_token = current_user.id_token if user_signed_in?
|
|
139
|
-
@llm_families = LlmMetaClient::ServerResource.available_llm_families(jwt_token)
|
|
157
|
+
# Add a prompt to a specific chat identified by URL uuid. This is the
|
|
158
|
+
# tab-safe entry point: chat identity comes from the URL, not session, so
|
|
159
|
+
# navigation in another tab can never re-target the prompt.
|
|
160
|
+
def add_prompt
|
|
161
|
+
jwt_token = current_user.jwt_token if user_signed_in?
|
|
140
162
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
current_user
|
|
144
|
-
)
|
|
145
|
-
@messages = @chat&.ordered_messages || []
|
|
146
|
-
# initialize history for the chat
|
|
147
|
-
initialize_history @chat&.ordered_by_descending_prompt_executions
|
|
148
|
-
# Set active message UUID for highlighting in UI
|
|
149
|
-
set_active_message_uuid(params.dig(:chat, :branch_from_uuid))
|
|
150
|
-
rescue StandardError => e
|
|
151
|
-
Rails.logger.error "Error in ChatsController#edit: #{e.class} - #{e.message}\n#{e.backtrace&.join("\n")}"
|
|
152
|
-
@llm_families = []
|
|
153
|
-
flash.now[:alert] = "Chat service is currently unavailable. Please try again later."
|
|
154
|
-
end
|
|
163
|
+
scope = user_signed_in? ? current_user.chats : Chat.where(user_id: nil)
|
|
164
|
+
@chat = scope.find_by!(uuid: params[:id])
|
|
155
165
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# Use the chat identified by the URL, not the session
|
|
160
|
-
@chat = current_user.chats.find(params[:id])
|
|
161
|
-
session[:chat_id] = @chat.id
|
|
166
|
+
initialize_chat current_user&.chats
|
|
167
|
+
add_chat @chat
|
|
168
|
+
set_active_chat_uuid(@chat&.uuid)
|
|
162
169
|
@messages = @chat&.ordered_messages || []
|
|
163
|
-
# initialize history for the chat
|
|
164
170
|
initialize_history @chat&.ordered_by_descending_prompt_executions
|
|
165
171
|
|
|
166
172
|
if params[:message].present?
|
|
167
|
-
# Validate generation settings before proceeding (raises if invalid).
|
|
168
|
-
# The streaming controller re-parses them from the URL.
|
|
169
173
|
begin
|
|
170
174
|
generation_settings_param
|
|
171
175
|
rescue InvalidGenerationSettingsError => e
|
|
172
176
|
@error_message = e.message
|
|
173
177
|
respond_to do |format|
|
|
174
|
-
format.turbo_stream
|
|
175
|
-
format.html { redirect_to chat_path(@chat), alert: e.message }
|
|
178
|
+
format.turbo_stream { render :create }
|
|
179
|
+
format.html { redirect_to chat_path(@chat.uuid), alert: e.message }
|
|
176
180
|
end
|
|
177
181
|
return
|
|
178
182
|
end
|
|
179
183
|
|
|
180
|
-
# Add user message (will be rendered via turbo stream)
|
|
181
184
|
@prompt_execution, @user_message = @chat.add_user_message(params[:message],
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
params[:api_key_uuid],
|
|
186
|
+
params[:model],
|
|
187
|
+
params[:branch_from_uuid],
|
|
188
|
+
llm_platform: params[:family],
|
|
189
|
+
image: uploaded_image_payload)
|
|
186
190
|
push_to_history @prompt_execution
|
|
187
|
-
# Set active message UUID for highlighting in UI
|
|
188
191
|
set_active_message_uuid(@prompt_execution&.execution_id || params.dig(:chat, :branch_from_uuid))
|
|
189
192
|
|
|
190
|
-
# The assistant response is streamed by ChatStreamsController (SSE).
|
|
191
|
-
# See create action for details.
|
|
192
193
|
@generation_settings_json = params[:generation_settings_json]
|
|
193
194
|
@tool_ids = Array(params[:tool_ids]).reject(&:blank?)
|
|
194
195
|
end
|
|
195
196
|
|
|
196
|
-
|
|
197
|
+
@llm_families = LlmMetaClient::ServerResource.available_llm_families(jwt_token) rescue []
|
|
198
|
+
|
|
197
199
|
respond_to do |format|
|
|
198
|
-
format.turbo_stream
|
|
199
|
-
format.html { redirect_to
|
|
200
|
+
format.turbo_stream { render :create }
|
|
201
|
+
format.html { redirect_to chat_path(@chat.uuid) }
|
|
200
202
|
end
|
|
203
|
+
rescue ActiveRecord::RecordNotFound
|
|
204
|
+
redirect_to root_path, alert: "Chat not found."
|
|
201
205
|
end
|
|
202
206
|
|
|
203
207
|
private
|
|
204
208
|
|
|
205
|
-
|
|
209
|
+
MAX_IMAGE_BYTES = 8 * 1024 * 1024 # 8 MB
|
|
206
210
|
|
|
207
211
|
class InvalidGenerationSettingsError < StandardError; end
|
|
208
212
|
|
|
213
|
+
# Read params[:image] (multipart upload) and return a transport-ready hash
|
|
214
|
+
# `{mime:, data_b64:}` or nil if absent / invalid. Validation is lenient:
|
|
215
|
+
# caller's add_user_message just embeds the data URI; the server enforces
|
|
216
|
+
# vision-model compatibility.
|
|
217
|
+
def uploaded_image_payload
|
|
218
|
+
upload = params[:image]
|
|
219
|
+
return nil if upload.blank? || !upload.respond_to?(:read)
|
|
220
|
+
bytes = upload.read
|
|
221
|
+
return nil if bytes.blank? || bytes.bytesize > MAX_IMAGE_BYTES
|
|
222
|
+
mime = upload.content_type.to_s
|
|
223
|
+
mime = "application/octet-stream" if mime.empty?
|
|
224
|
+
{ mime: mime, data_b64: Base64.strict_encode64(bytes) }
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
Rails.logger.warn "Image upload read failed: #{e.class}: #{e.message}"
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Pass-through generation settings: accept any JSON object. Values can be
|
|
231
|
+
# numbers, booleans, strings, or nested hashes (e.g. Ollama's `options`).
|
|
232
|
+
# The provider gem / upstream API decides which keys it understands;
|
|
233
|
+
# unknown keys are typically ignored by the provider.
|
|
209
234
|
def generation_settings_param
|
|
210
235
|
return {} if params[:generation_settings_json].blank?
|
|
211
236
|
|
|
212
237
|
parsed = JSON.parse(params[:generation_settings_json])
|
|
213
238
|
raise InvalidGenerationSettingsError, "Generation settings must be a JSON object" unless parsed.is_a?(Hash)
|
|
214
239
|
|
|
215
|
-
|
|
216
|
-
invalid_keys = parsed.keys - ALLOWED_GENERATION_KEYS
|
|
217
|
-
raise InvalidGenerationSettingsError, "Unknown keys: #{invalid_keys.join(', ')}" if invalid_keys.any?
|
|
218
|
-
|
|
219
|
-
non_numeric = settings.reject { |_k, v| v.is_a?(Numeric) }
|
|
220
|
-
if non_numeric.any?
|
|
221
|
-
raise InvalidGenerationSettingsError, "Values must be numeric: #{non_numeric.keys.join(', ')}"
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
settings.symbolize_keys
|
|
240
|
+
parsed.deep_symbolize_keys
|
|
225
241
|
rescue JSON::ParserError => e
|
|
226
242
|
raise InvalidGenerationSettingsError, "Invalid JSON: #{e.message}"
|
|
227
243
|
end
|
data/lib/generators/llm_meta_client/scaffold/templates/app/controllers/prompts_controller.rb
CHANGED
|
@@ -16,7 +16,7 @@ class PromptsController < ApplicationController
|
|
|
16
16
|
initialize_history @chat.ordered_by_descending_prompt_executions
|
|
17
17
|
|
|
18
18
|
# Get LLM options available for users
|
|
19
|
-
jwt_token = current_user.
|
|
19
|
+
jwt_token = current_user.jwt_token if user_signed_in?
|
|
20
20
|
@llm_families = LlmMetaClient::ServerResource.available_llm_families(jwt_token)
|
|
21
21
|
|
|
22
22
|
# Set the target message ID for scrolling
|
|
@@ -33,4 +33,31 @@ class PromptsController < ApplicationController
|
|
|
33
33
|
Rails.logger.error "Error in PromptsController#show_by_uuid: #{e.class} - #{e.message}\n#{e.backtrace&.join("\n")}"
|
|
34
34
|
redirect_to root_path, alert: "Message not found."
|
|
35
35
|
end
|
|
36
|
+
|
|
37
|
+
# Leaf-only delete. Re-checks leaf status server-side (a branch could have
|
|
38
|
+
# been added after the page rendered) so the tree can never be corrupted.
|
|
39
|
+
def destroy
|
|
40
|
+
pe = PromptNavigator::PromptExecution.find_by!(execution_id: params[:id])
|
|
41
|
+
scope = user_signed_in? ? current_user.chats : Chat.where(user_id: nil)
|
|
42
|
+
chat = Message.where(prompt_navigator_prompt_execution_id: pe.id).first&.chat
|
|
43
|
+
|
|
44
|
+
unless chat && scope.exists?(id: chat.id)
|
|
45
|
+
redirect_to(root_path, alert: "Prompt not found.")
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if PromptNavigator::PromptExecution.where(previous_id: pe.id).exists?
|
|
50
|
+
redirect_to(prompt_path(pe.execution_id), alert: "Cannot delete: this prompt has follow-up branches.")
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ActiveRecord::Base.transaction do
|
|
55
|
+
Message.where(prompt_navigator_prompt_execution_id: pe.id).delete_all
|
|
56
|
+
pe.delete
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
redirect_to chat_path(chat.uuid), notice: "Prompt deleted."
|
|
60
|
+
rescue ActiveRecord::RecordNotFound
|
|
61
|
+
redirect_to root_path, alert: "Prompt not found."
|
|
62
|
+
end
|
|
36
63
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="asset-actions"
|
|
4
|
+
//
|
|
5
|
+
// Powers the Download / Copy buttons rendered next to assistant-generated
|
|
6
|
+
// assets (images and copyable code blocks — JSON, CSV). Markup is emitted
|
|
7
|
+
// by ApplicationHelper::AssistantResponseRenderer.
|
|
8
|
+
//
|
|
9
|
+
// Values:
|
|
10
|
+
// kindValue — "image" or "text"; chooses copy/download strategy
|
|
11
|
+
// hrefValue — for kind=image, the image src (data URL or http)
|
|
12
|
+
// filenameValue — suggested download filename
|
|
13
|
+
// mimeValue — for kind=text, the MIME type of the generated Blob
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
static values = {
|
|
16
|
+
kind: { type: String, default: "text" },
|
|
17
|
+
href: { type: String, default: "" },
|
|
18
|
+
filename: { type: String, default: "download" },
|
|
19
|
+
mime: { type: String, default: "text/plain" },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async download(event) {
|
|
23
|
+
const btn = event.currentTarget
|
|
24
|
+
try {
|
|
25
|
+
if (this.kindValue === "image") {
|
|
26
|
+
this.#triggerDownload(this.hrefValue, this.filenameValue)
|
|
27
|
+
} else {
|
|
28
|
+
const text = this.#extractText()
|
|
29
|
+
const blob = new Blob([text], { type: this.mimeValue })
|
|
30
|
+
const url = URL.createObjectURL(blob)
|
|
31
|
+
this.#triggerDownload(url, this.filenameValue)
|
|
32
|
+
// Yield a tick before revoking so the browser has time to start
|
|
33
|
+
// the download — Blob URLs revoked too eagerly cancel the file.
|
|
34
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
35
|
+
}
|
|
36
|
+
this.#flashOk(btn)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error("[asset-actions] download failed", e)
|
|
39
|
+
this.#flashFail(btn)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async copyText(event) {
|
|
44
|
+
const btn = event.currentTarget
|
|
45
|
+
try {
|
|
46
|
+
await navigator.clipboard.writeText(this.#extractText())
|
|
47
|
+
this.#flashOk(btn)
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error("[asset-actions] copyText failed", e)
|
|
50
|
+
this.#flashFail(btn)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async copyImage(event) {
|
|
55
|
+
const btn = event.currentTarget
|
|
56
|
+
try {
|
|
57
|
+
const resp = await fetch(this.hrefValue)
|
|
58
|
+
const blob = await resp.blob()
|
|
59
|
+
if (typeof ClipboardItem === "undefined") {
|
|
60
|
+
throw new Error("ClipboardItem unsupported in this browser")
|
|
61
|
+
}
|
|
62
|
+
await navigator.clipboard.write([
|
|
63
|
+
new ClipboardItem({ [blob.type]: blob }),
|
|
64
|
+
])
|
|
65
|
+
this.#flashOk(btn)
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error("[asset-actions] copyImage failed", e)
|
|
68
|
+
this.#flashFail(btn)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#triggerDownload(href, filename) {
|
|
73
|
+
const a = document.createElement("a")
|
|
74
|
+
a.href = href
|
|
75
|
+
a.download = filename
|
|
76
|
+
document.body.appendChild(a)
|
|
77
|
+
a.click()
|
|
78
|
+
a.remove()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#extractText() {
|
|
82
|
+
const code = this.element.querySelector("code")
|
|
83
|
+
return (code ? code.textContent : this.element.textContent) || ""
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Brief icon flip so the user sees the click registered. 1.2s is long
|
|
87
|
+
// enough to read, short enough to not collide with a second click.
|
|
88
|
+
#flashOk(btn) { this.#flashIcon(btn, "bi-check-lg") }
|
|
89
|
+
#flashFail(btn) { this.#flashIcon(btn, "bi-exclamation-circle") }
|
|
90
|
+
|
|
91
|
+
#flashIcon(btn, replacementClass) {
|
|
92
|
+
const icon = btn?.querySelector("i")
|
|
93
|
+
if (!icon) return
|
|
94
|
+
const original = icon.className
|
|
95
|
+
icon.className = `bi ${replacementClass}`
|
|
96
|
+
setTimeout(() => { icon.className = original }, 1200)
|
|
97
|
+
}
|
|
98
|
+
}
|