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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -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 +128 -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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fcc6377f3293f8ecd13b81cd79ea63891c18f55dfa05ee225a151f7e1fa5b84
4
- data.tar.gz: 35c4cba209aed5989b43606715205a7e2b85c669fa7dd71d65a33f4dd47a293a
3
+ metadata.gz: 392fd4f6a93475e739beef49bf50b8158fced469993b78875a22845777bcf96b
4
+ data.tar.gz: '090a33919e5ac2916b1d5af16b11e9e831f954c00dc1a61f1459db931f3f02f8'
5
5
  SHA512:
6
- metadata.gz: c959d77e7d3b8c9f5070bf2f63a74e83ba1082391694788e094c09629e76883dcf0142336af7742dd08fa46c0d36ec20ad31ad85b558ea4199cb8b7e3c4345fc
7
- data.tar.gz: 651d88ddb211fd11234daf2ecee22e368d8886d27e43b94c1786d4d3980cedfb078a562ee0cd0ad06a48140c7fb07cae779326237e604b21a819d4ceb663d815
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/_family_field.html.erb"
38
- template "app/views/shared/_api_key_field.html.erb"
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/llm_selector_controller.js"
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, :edit, :update, :show ] do
74
+ resources :chats, only: [ :new, :create, :show, :destroy ] do
72
75
  collection do
73
76
  delete :clear
74
77
  post :start_new
75
- get :download_all_csv
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
@@ -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.id_token
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.id_token
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
@@ -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.id_token if user_signed_in?
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, :download_all_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
- session[:chat_id] = @chat.id
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.id_token if user_signed_in?
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
- # Find current conversation or create it on create method if not found
37
- @chat = Chat.find_or_switch_for_session(
38
- session,
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
- # Get LLM options available for users
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.id_token if user_signed_in?
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
- # Find or create chat
62
- @chat = Chat.find_or_switch_for_session(
63
- session,
64
- current_user
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
- def edit
134
- # Initialize chat context
135
- initialize_chat current_user&.chats
136
-
137
- # Get LLM options available for users
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
- @chat = Chat.find_or_switch_for_session(
142
- session,
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
- def update
157
- jwt_token = current_user.id_token if user_signed_in?
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
- params[:api_key_uuid],
183
- params[:model],
184
- params[:branch_from_uuid])
185
- # Push to history for rendering
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
- # Return turbo stream to render both messages
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 new_chat_path }
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
- ALLOWED_GENERATION_KEYS = %w[temperature top_k top_p max_tokens repeat_penalty].freeze
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
- settings = parsed.slice(*ALLOWED_GENERATION_KEYS)
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
@@ -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.id_token if user_signed_in?
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
+ }