Class: Crm::AssistantChatController
- Inherits:
-
CrmController
- Object
- ActionController::Base
- ApplicationController
- CrmController
- Crm::AssistantChatController
- Defined in:
- app/controllers/crm/assistant_chat_controller.rb
Overview
Controller for AI-powered assistant
Allows all CRM employees to query data, search content, and get help using natural language
Uses Turbo Streams + ActionCable for real-time streaming responses.
The ask action enqueues a Sidekiq worker that broadcasts chunks via Turbo Streams.
Access: All employees (tool access varies by role)
Routes: /assistant (primary)
Constant Summary collapse
- ATTACHMENT_MAX_BYTES =
POST /assistant/upload_attachment
Upload a file (PDF, image, text) to attach to the next message.
Returns JSON filename, mime_type, size on success.
The file is stored as an Upload record linked to the conversation (or temporarily
to the current user's most-recent conversation — the worker re-links on send). 25.megabytes
- ATTACHMENT_MIME_ALLOWLIST =
%w[ application/pdf image/png image/jpeg image/gif image/webp text/plain text/csv ].freeze
Constants included from Controllers::ReferenceFindable
Controllers::ReferenceFindable::ID_EMBEDDED_PATTERNS
Constants included from Controllers::AnalyticsEvents
Controllers::AnalyticsEvents::MAX_QUEUED_EVENTS, Controllers::AnalyticsEvents::SESSION_KEY
Constants included from Controllers::ErrorRendering
Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES
Constants included from Www::SeoHelper
Www::SeoHelper::AWARDS, Www::SeoHelper::CA_ADDRESS, Www::SeoHelper::CA_BUSINESS_HOURS, Www::SeoHelper::CA_CONTACT_POINT, Www::SeoHelper::CA_CURRENCIES, Www::SeoHelper::CA_DESCRIPTION, Www::SeoHelper::CA_FOUNDING_DATE, Www::SeoHelper::CA_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::CA_LEGAL_NAME, Www::SeoHelper::CA_LOCAL_BUSINESS, Www::SeoHelper::CA_ONLINE_STORE, Www::SeoHelper::CA_RETURN_POLICY, Www::SeoHelper::CA_SALES_DEPARTMENT, Www::SeoHelper::CA_SERVICE_AREA, Www::SeoHelper::CA_URL, Www::SeoHelper::CA_VAT_ID, Www::SeoHelper::CA_WAREHOUSE_DEPARTMENT, Www::SeoHelper::CA_WAREHOUSE_HOURS, Www::SeoHelper::COMPANY_EMAIL, Www::SeoHelper::COMPANY_LOGO, Www::SeoHelper::COMPANY_NAME, Www::SeoHelper::COMPANY_SLOGAN, Www::SeoHelper::EXPERTISE, Www::SeoHelper::FAX_NUMBER, Www::SeoHelper::GS1_COMPANY_PREFIX, Www::SeoHelper::ISO6523_CODE, Www::SeoHelper::PAYMENT_METHODS, Www::SeoHelper::PHONE_NUMBER, Www::SeoHelper::PRIMARY_NAICS, Www::SeoHelper::REFUND_TYPE, Www::SeoHelper::RETURN_FEES, Www::SeoHelper::RETURN_METHOD, Www::SeoHelper::RETURN_POLICY_CATEGORY, Www::SeoHelper::SECONDARY_NAICS, Www::SeoHelper::SOCIAL_PROFILES, Www::SeoHelper::US_ADDRESS, Www::SeoHelper::US_BUSINESS_HOURS, Www::SeoHelper::US_CONTACT_POINT, Www::SeoHelper::US_CURRENCIES, Www::SeoHelper::US_DESCRIPTION, Www::SeoHelper::US_FOUNDING_DATE, Www::SeoHelper::US_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::US_IMAGE, Www::SeoHelper::US_LEGAL_NAME, Www::SeoHelper::US_LOCAL_BUSINESS, Www::SeoHelper::US_ONLINE_STORE, Www::SeoHelper::US_RETURN_POLICY, Www::SeoHelper::US_SALES_DEPARTMENT, Www::SeoHelper::US_SERVICE_AREA, Www::SeoHelper::US_TAX_ID, Www::SeoHelper::US_URL, Www::SeoHelper::US_WAREHOUSE_DEPARTMENT, Www::SeoHelper::US_WAREHOUSE_HOURS
Constants included from IconHelper
IconHelper::CUSTOM_ICON_MAP, IconHelper::CUSTOM_SVG_DIR, IconHelper::DEFAULT_FAMILY
Class Method Summary collapse
-
.controller_path ⇒ Object
Views live in app/views/crm/assistant_chat/.
Instance Method Summary collapse
-
#ask ⇒ Object
POST /assistant/ask Enqueue a background job to process the query Returns immediately with Turbo Stream that sets up the UI Worker broadcasts response chunks via ActionCable.
-
#cancel ⇒ Object
POST /assistant/cancel Cancel an in-progress chat job.
-
#create ⇒ Object
POST /assistant/ask Create a new conversation.
-
#destroy ⇒ Object
DELETE /assistant/:id Delete a conversation (owner only).
-
#destroy_message ⇒ Object
DELETE /assistant/:id/destroy_message Delete a single message from a conversation (owner/collaborator only).
-
#fork ⇒ Object
POST /assistant/fork Proactively fork a long conversation into a continuation with summarized context.
-
#fork_from_message ⇒ Object
POST /assistant/fork_from_message Branch a conversation from a specific user message.
-
#histories ⇒ Object
GET /assistant/histories Paginated index of all conversations for the current user.
-
#index ⇒ Object
GET /assistant Start a fresh conversation so users don't unknowingly pile onto an old thread with stale context.
-
#processing_status ⇒ Object
GET /assistant/processing_status?conversation_id=123 Lightweight JSON check used by the Stimulus controller to detect when the worker has finished but the completion broadcast was missed (e.g. ActionCable disconnect during laptop sleep, tab backgrounding).
-
#show ⇒ Object
GET /assistant/:id Load a specific conversation (owned or shared).
-
#update ⇒ Object
PATCH /assistant/:id Rename a conversation (owner only).
- #upload_attachment ⇒ Object
Methods inherited from CrmController
#access_denied, #context_id, #context_object, #crm_home_path, #current_ability, #default_url_options, #download_temp, #get_tempfile_path_for_download, #initialize_crm_lazy_chunks, #record_not_found, #redirect_to_job_or_fallback, #render_edit_action, #set_context, #set_download_path, #stash_file_for_temp_download
Methods inherited from ApplicationController
#account_impersonated?, #add_to_flash, #append_token, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #stamp_impersonation_context, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y
Methods included from Controllers::ReturnPathHandling
#check_for_return_path, #redirect_to_return_path_or_default
Methods included from Controllers::AnalyticsEvents
#consume_queued_analytics_events, #track_event
Methods included from Controllers::DeviceDetection
Methods included from Controllers::SubdomainDetection
#is_crm_request?, #is_www_request?, #json_request?
Methods included from Controllers::TrackingDetection
#bot_request?, #gdpr_country?, #gdpr_country_data, #prevent_bots, #set_tracking_cookie, #track_visitor?
Methods included from Controllers::AcceleratedFileSending
#send_file_accelerated, #send_upload_accelerated
Methods included from Controllers::ErrorRendering
#excp_string, #mail_to_for_error_reporting, #render_400, #render_404, #render_406, #render_410, #render_500, #render_invalid_authenticity_token, #render_ip_spoof_error, #safe_referer_or_fallback
Methods included from Controllers::TurnstileVerification
#load_turnstile_script_tag, #turnstile_lazy_widget, #turnstile_script_tag, #turnstile_widget, #validate_turnstile!
Methods included from Controllers::CloudflareCaching
edge_cached, #edge_cached_action?, #reset_cloudflare_cache, #set_cloudflare_cache, #skip_session
Methods included from Controllers::Webpackable
#preload_webpack_fonts, #webpack_css_include, #webpack_css_url, #webpack_js_include, #wpd_is_running?
Methods included from Controllers::Localizable
#cloudflare_country_locale, #determine_request_locale, #geocoder_locale, #guest_user_locale_check, #locale_optional_www_auth_path?, #param_locale, #set_locale, #set_request_locale, #skip_localization?, #warmlyyours_ip_locale
Methods included from Controllers::Authenticable
#access_denied, #authenticate_account, #authenticate_account!, #authenticate_account_from_login_token!, #authenticate_account_from_token!, #check_is_a_manager, #check_is_a_sales_manager, #check_is_an_admin, #check_is_an_employee, #check_party, #clear_mismatched_guest_user, #create_guest_user, #credentials?, #current_or_guest_user, #current_or_guest_user_id_read_only, #current_user, #devise_mapping, #fully_logged_in?, #generate_bot_id, #guest_user, #identifiable?, #init_current_user, #initialize_guest, #load_context_user, #logging_in, #resource, #resource_name, #restrict_access_for_non_employees, #scrubbed_request_path, #user_object, #warn_on_session_guest_id_leak
Methods included from ApplicationHelper
#better_number_to_currency, #check_force_logout, #check_or_cross, #check_or_times, #error_messages, #general_disclaimer_on_product_installation_and_local_codes, #gridjs_from_html_table, #gridjs_table, #is_wy_ip, #line_break, #parent_layout, #pass_or_fail, #render_error_messages_list, #render_video_card, #resolved_auth_form_turbo_frame, #return_path_or, #safe_css_color, #set_return_path_if_present, #set_section_if_present, #tab_frame_id, #to_underscore, #track_page?, #turbo_section_wrapper, #turbo_tabs_request?, #url_on_same_domain_as_request, #widget_index_daily_focus_index_path, #working_hours?, #yes_or_no, #yes_or_no_highlighted, #yes_or_no_with_check_or_cross, #youtube_video
Methods included from UppyUploaderHelper
#file_uploader, #image_uploader, #large_file_uploader_s3, #lead_sketch_uploader, #rma_image_uploader, #rma_image_uploader_s3, #uppy_uploader, #video_uploader
Methods included from Www::ImagesHelper
#image_asset_tag, #image_asset_url
Methods included from Www::SeoHelper
#add_page_schema, #canada?, #company_social_links, #ensure_context_json, #json_ld_script_tag, #local_business_schema, #online_store_id, #online_store_schema, #page_main_entity, #page_main_entity_json, #render_auto_collection_page_schema, #render_collection_page_schema, #render_local_business_schema, #render_online_store_schema, #render_page_schemas, #render_page_video_schemas, #render_webpage_schema, #render_webpage_schema_with_collections, #usa?
Methods included from UrlsHelper
#catalog_breadcrumb_links, #catalog_link, #catalog_link_for_product_line, #catalog_link_for_sku, #cms_link, #delocalized_path, #path_to_sales_product_sku, #path_to_sales_product_sku_for_product_line, #path_to_sales_product_sku_for_product_line_slug, #product_line_from_catalog_link, #protocol_neutral_url, #sanitize_external_url, #valid_external_url?
Methods included from IconHelper
#account_nav_icon, #fa_icon, #star_rating_html
Class Method Details
.controller_path ⇒ Object
Views live in app/views/crm/assistant_chat/.
Override the default controller_path inference so Rails finds templates there.
15 16 17 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 15 def self.controller_path 'crm/assistant_chat' end |
Instance Method Details
#ask ⇒ Object
POST /assistant/ask
Enqueue a background job to process the query
Returns immediately with Turbo Stream that sets up the UI
Worker broadcasts response chunks via ActionCable
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 135 def ask @user_message = params[:message].to_s.strip conversation_id = params[:conversation_id] if @user_message.blank? return respond_to do |format| format.turbo_stream do render turbo_stream: turbo_stream.append('chat-messages', html: '<div class="alert alert-warning">Please enter a message.</div>'.html_safe) end format.html { redirect_to assistant_index_path, alert: 'Message is required' } format.json { render json: { error: 'Message is required' }, status: :unprocessable_entity } end end dispatch_ask_request(conversation_id) end |
#cancel ⇒ Object
POST /assistant/cancel
Cancel an in-progress chat job
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 284 def cancel job_id = params[:job_id] if job_id.present? # Set cancellation flag in Redis (worker checks this). # # TTL must outlast the worst-case in-flight turn or the flag expires before # the worker next polls it, silently turning the cancel into a no-op. A # single plan step is capped at ToolLoopGuard::MAX_STEP_DURATION (600s), # multi-step plans run several of those back-to-back, and emergency # compaction + retry can extend a turn further still. 7200s (2h) is well # above any realistic in-flight turn while still self-cleaning. # # Regression: 5-minute TTL caused a user-visible cancel in conversation 1525 # to be ignored for 37 minutes — the flag expired before the next plan-step # boundary and 3 more steps ran to completion. Sidekiq.redis { |conn| conn.set("cancelled:#{job_id}", '1', ex: 7200) } Rails.logger.info("[AssistantChat] Cancelled job #{job_id}") end respond_to do |format| format.turbo_stream do streams = [] # Remove the processing indicator (spinner + stop button) streams << turbo_stream.remove("chat-processing-#{job_id}") streams << turbo_stream.replace( "chat-response-#{job_id}", html: '<div class="message-content text-muted fst-italic"><i class="fa-solid fa-circle-xmark me-2"></i>Query cancelled.<span data-chat-complete="true" style="display:none;"></span></div>'.html_safe ) render turbo_stream: streams end format.json { render json: { cancelled: true } } end end |
#create ⇒ Object
POST /assistant/ask
Create a new conversation. Accepts optional prefill param to pre-populate the
textarea on load, and default_tools to auto-enable tool chips
(used by "Discuss in Sunny" buttons across the CRM). Both are stored on the
conversation itself so they survive any redirect and a page refresh.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 112 def create @conversation = Assistant::SunnyAgent.create!(user: current_user) attrs = {} attrs[:draft_prefill] = params[:prefill] if params[:prefill].present? if params[:default_tools].present? handles = Array(params[:default_tools]) attrs[:default_tool_handles] = handles service_keys = Assistant::ToolRouter.resolve_handles(handles) if service_keys.any? attrs[:tool_services] = service_keys attrs[:conversation_type] = params[:conversation_type] || 'preset_tools' end end @conversation.update!(attrs) if attrs.any? redirect_to assistant_path(@conversation) end |
#destroy ⇒ Object
DELETE /assistant/:id
Delete a conversation (owner only).
Always responds with a Turbo Stream that removes the sidebar item in
place — no full-page reload so the user clearly sees it disappear.
340 341 342 343 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 340 def destroy @conversation = current_user.assistant_conversations.find(params[:id]) @conversation.destroy end |
#destroy_message ⇒ Object
DELETE /assistant/:id/destroy_message
Delete a single message from a conversation (owner/collaborator only).
347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 347 def @conversation = find_accessible_conversation(params[:id]) return head(:not_found) unless @conversation access = @conversation.access_level_for(current_user, current_account) return head(:forbidden) unless %w[owner collaborator].include?(access) = @conversation..find_by(id: params[:message_id], role: %w[user assistant]) return head(:not_found) unless .destroy! respond_to do |format| format.turbo_stream do render turbo_stream: turbo_stream.remove("chat-message-#{.id}") end format.html { redirect_to assistant_path(@conversation) } end end |
#fork ⇒ Object
POST /assistant/fork
Proactively fork a long conversation into a continuation with summarized context.
Triggered by the "continuation with context" nudge button.
Summarization is deferred to the worker (runs before the first LLM call via
generate_parent_summary!) so the redirect is instant — no Cloudflare timeout
risk for large conversations.
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 159 def fork source = find_accessible_conversation(params[:conversation_id]) return redirect_to assistant_index_path, alert: 'Conversation not found.' unless source continuation = Assistant::ContextCompactor.fork_continuation!( source, tool_services: source.tool_services, summarize: false ) if continuation redirect_to assistant_path(continuation), notice: 'Continued in a new chat with your conversation context.' else redirect_to assistant_path(source), alert: 'Could not create continuation. Please try again.' end end |
#fork_from_message ⇒ Object
POST /assistant/fork_from_message
Branch a conversation from a specific user message.
Summarizes messages before the fork point and creates a fresh conversation
with that context. Redirects to the new chat with the message pre-filled.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 180 def conversation = find_accessible_conversation(params[:conversation_id]) return redirect_to assistant_index_path, alert: 'Conversation not found.' unless conversation result = Assistant::ContextCompactor.( conversation, message_id: params[:message_id].to_i, summarize: false ) if result fork_convo, prefill_text = result if prefill_text.present? # Store the original message in the fork so the LLM sees it in history, # then immediately enqueue a worker — matches the overflow fork behaviour. model = conversation.model_preference.presence || 'auto' tool_services = Array(conversation.tool_services) # Carry over uploads from the source conversation so forked messages # retain their attachments (images, PDFs, etc.) for multimodal processing. source_upload_ids = conversation.uploads.pluck(:id) AssistantChatWorker.perform_async( fork_convo.id, prefill_text, model, tool_services, build_user_context, source_upload_ids ) flash[:fork_pending_message] = prefill_text redirect_to assistant_path(fork_convo), notice: 'Branched — Sunny is replying in the new conversation.' else redirect_to assistant_path(fork_convo), notice: 'New branch created — context from the original conversation has been summarized.' end else redirect_to assistant_path(conversation), alert: 'Could not create branch. Please try again.' end end |
#histories ⇒ Object
GET /assistant/histories
Paginated index of all conversations for the current user.
Users with the sunny_administrator role can additionally filter by any
combination of other users (or view all users when none are selected).
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 71 def histories @sunny_admin = can?(:manage, AssistantBrainEntry) base = if @sunny_admin && params[:user_ids].present? ids = Array(params[:user_ids]).map(&:to_i).select(&:positive?) ids.any? ? AssistantConversation.where(user_id: ids) : AssistantConversation.all elsif @sunny_admin && params[:user_ids] == [''] AssistantConversation.all else current_user.assistant_conversations end base = base.search_content(params[:search].strip) if params[:search].present? @search = base.ransack(params[:q]) @search.sorts = 'updated_at desc' if @search.sorts.empty? # Compute aggregate stats across the full filtered set (before pagination). # Uses COALESCE to handle conversations where metadata fields are NULL. filtered = @search.result @total_conversations = filtered.count @total_queries = filtered.sum(Arel.sql("COALESCE((metadata->>'total_queries')::int, 0)")) @total_tokens = filtered.sum(Arel.sql( "COALESCE((metadata->>'total_input_tokens')::int, 0) + " \ "COALESCE((metadata->>'total_output_tokens')::int, 0)" )) @total_cost = filtered.sum(Arel.sql("COALESCE((metadata->>'total_cost_cents')::float, 0)")) @pagy, @conversations = pagy( filtered.includes(:user, :shares), limit: 25 ) @selectable_users = Employee. if @sunny_admin end |
#index ⇒ Object
GET /assistant
Start a fresh conversation so users don't unknowingly pile onto
an old thread with stale context. Reuses the most recent conversation
if it has no messages yet (avoids accumulating empty records).
27 28 29 30 31 32 33 34 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 27 def index @conversation = if @conversation && @conversation..none? @conversation else Assistant::SunnyAgent.create!(user: current_user) end redirect_to assistant_path(@conversation) end |
#processing_status ⇒ Object
GET /assistant/processing_status?conversation_id=123
Lightweight JSON check used by the Stimulus controller to detect when
the worker has finished but the completion broadcast was missed
(e.g. ActionCable disconnect during laptop sleep, tab backgrounding).
Returns stalled: true when the heartbeat stopped (worker likely killed
during deploy) but the lock hasn't expired yet, so the client can show
an early warning.
326 327 328 329 330 331 332 333 334 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 326 def processing_status conversation = find_accessible_conversation(params[:conversation_id]) return head :not_found unless conversation render json: { processing: conversation.processing?, stalled: conversation.heartbeat_stale? } end |
#show ⇒ Object
GET /assistant/:id
Load a specific conversation (owned or shared).
Admins may access any conversation for troubleshooting purposes.
Responds to both HTML and Turbo Stream. The Turbo Stream variant is needed
because Turbo follows redirects from a turbo_stream form submission with the
same text/vnd.turbo-stream.html Accept header. Without an explicit
turbo_stream response, Rails would serve the full HTML page with a
turbo_stream content type, and Turbo's stream parser would choke on the
body's HTML entities ("Unexpected token '&'"). Returning a redirect
turbo-stream tells the client to do a real Turbo.visit (which uses an
HTML Accept header), breaking the loop.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 48 def show @conversation = find_accessible_conversation(params[:id]) unless @conversation fallback = current_user.assistant_conversations.recent.first || Assistant::SunnyAgent.create!(user: current_user) return respond_to_show_redirect(assistant_path(fallback), alert: 'Conversation not found') end respond_to do |format| format.html do prepare_show_data render :index end format.turbo_stream do render turbo_stream: turbo_stream.redirect(assistant_path(@conversation)) end end end |
#update ⇒ Object
PATCH /assistant/:id
Rename a conversation (owner only). Updates breadcrumb + sidebar via Turbo Stream.
369 370 371 372 373 374 375 376 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 369 def update @conversation = current_user.assistant_conversations.find(params[:id]) active_id = params[:active_conversation_id].presence&.to_i @active_conversation_id = active_id.positive? ? active_id : @conversation.id if @conversation.update(assistant_conversation_params) @sidebar_is_unread = load_read_map([@conversation])[@conversation] || false end end |
#upload_attachment ⇒ Object
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 |
# File 'app/controllers/crm/assistant_chat_controller.rb', line 236 def file = params[:file] conversation_id = params[:conversation_id] unless file.is_a?(ActionDispatch::Http::UploadedFile) return render json: { error: 'No file provided' }, status: :unprocessable_entity end if file.size > ATTACHMENT_MAX_BYTES return render json: { error: 'File exceeds the 25 MB limit' }, status: :unprocessable_entity end mime = Marcel::MimeType.for(file.tempfile, name: file.original_filename) unless ATTACHMENT_MIME_ALLOWLIST.any? { |allowed| mime.start_with?(allowed.split('/').first) && mime == allowed } return render json: { error: "File type #{mime} is not supported. Please upload a PDF, image, or plain text file." }, status: :unprocessable_entity end # Link to the supplied conversation, or fall back to a recent/new one conversation = if conversation_id.present? find_accessible_conversation(conversation_id) else current_user.assistant_conversations.recent.first || Assistant::SunnyAgent.create!(user: current_user) end upload = Upload.uploadify( file.tempfile.path.to_s, :assistant_attachment, conversation ) # Dragonfly overwrites attachment_name with the tempfile's basename during # the attachment= assignment, so we force the original filename afterwards. upload.update_column(:attachment_name, file.original_filename) render json: { upload_id: upload.id, filename: file.original_filename, mime_type: mime, size: file.size } rescue StandardError => e Rails.logger.error("[AssistantChat] upload_attachment failed: #{e.}") render json: { error: 'Upload failed. Please try again.' }, status: :unprocessable_entity end |