active_canvas 0.0.1

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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +318 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/active_canvas/editor/ai_panel.js +1607 -0
  6. data/app/assets/javascripts/active_canvas/editor/asset_manager.js +498 -0
  7. data/app/assets/javascripts/active_canvas/editor/blocks.js +1083 -0
  8. data/app/assets/javascripts/active_canvas/editor/code_panel.js +572 -0
  9. data/app/assets/javascripts/active_canvas/editor/component_toolbar.js +394 -0
  10. data/app/assets/javascripts/active_canvas/editor/panels.js +460 -0
  11. data/app/assets/javascripts/active_canvas/editor/utils.js +56 -0
  12. data/app/assets/javascripts/active_canvas/editor.js +295 -0
  13. data/app/assets/stylesheets/active_canvas/application.css +15 -0
  14. data/app/assets/stylesheets/active_canvas/editor.css +2929 -0
  15. data/app/controllers/active_canvas/admin/ai_controller.rb +181 -0
  16. data/app/controllers/active_canvas/admin/application_controller.rb +56 -0
  17. data/app/controllers/active_canvas/admin/media_controller.rb +61 -0
  18. data/app/controllers/active_canvas/admin/page_types_controller.rb +57 -0
  19. data/app/controllers/active_canvas/admin/page_versions_controller.rb +23 -0
  20. data/app/controllers/active_canvas/admin/pages_controller.rb +133 -0
  21. data/app/controllers/active_canvas/admin/partials_controller.rb +88 -0
  22. data/app/controllers/active_canvas/admin/settings_controller.rb +256 -0
  23. data/app/controllers/active_canvas/application_controller.rb +20 -0
  24. data/app/controllers/active_canvas/pages_controller.rb +18 -0
  25. data/app/controllers/concerns/active_canvas/current_user.rb +12 -0
  26. data/app/controllers/concerns/active_canvas/rate_limitable.rb +75 -0
  27. data/app/controllers/concerns/active_canvas/tailwind_compilation.rb +39 -0
  28. data/app/helpers/active_canvas/application_helper.rb +4 -0
  29. data/app/jobs/active_canvas/application_job.rb +4 -0
  30. data/app/jobs/active_canvas/compile_tailwind_job.rb +64 -0
  31. data/app/mailers/active_canvas/application_mailer.rb +6 -0
  32. data/app/models/active_canvas/ai_model.rb +136 -0
  33. data/app/models/active_canvas/application_record.rb +5 -0
  34. data/app/models/active_canvas/media.rb +141 -0
  35. data/app/models/active_canvas/page.rb +85 -0
  36. data/app/models/active_canvas/page_type.rb +22 -0
  37. data/app/models/active_canvas/page_version.rb +80 -0
  38. data/app/models/active_canvas/partial.rb +73 -0
  39. data/app/models/active_canvas/setting.rb +292 -0
  40. data/app/services/active_canvas/ai_configuration.rb +40 -0
  41. data/app/services/active_canvas/ai_models.rb +128 -0
  42. data/app/services/active_canvas/ai_service.rb +289 -0
  43. data/app/services/active_canvas/content_sanitizer.rb +112 -0
  44. data/app/services/active_canvas/tailwind_compiler.rb +156 -0
  45. data/app/views/active_canvas/admin/media/index.html.erb +401 -0
  46. data/app/views/active_canvas/admin/media/show.html.erb +297 -0
  47. data/app/views/active_canvas/admin/page_types/_form.html.erb +25 -0
  48. data/app/views/active_canvas/admin/page_types/edit.html.erb +13 -0
  49. data/app/views/active_canvas/admin/page_types/index.html.erb +29 -0
  50. data/app/views/active_canvas/admin/page_types/new.html.erb +9 -0
  51. data/app/views/active_canvas/admin/page_types/show.html.erb +18 -0
  52. data/app/views/active_canvas/admin/page_versions/show.html.erb +469 -0
  53. data/app/views/active_canvas/admin/pages/_form.html.erb +62 -0
  54. data/app/views/active_canvas/admin/pages/content.html.erb +139 -0
  55. data/app/views/active_canvas/admin/pages/edit.html.erb +335 -0
  56. data/app/views/active_canvas/admin/pages/editor.html.erb +710 -0
  57. data/app/views/active_canvas/admin/pages/index.html.erb +149 -0
  58. data/app/views/active_canvas/admin/pages/new.html.erb +19 -0
  59. data/app/views/active_canvas/admin/pages/show.html.erb +258 -0
  60. data/app/views/active_canvas/admin/pages/versions.html.erb +333 -0
  61. data/app/views/active_canvas/admin/partials/edit.html.erb +182 -0
  62. data/app/views/active_canvas/admin/partials/editor.html.erb +703 -0
  63. data/app/views/active_canvas/admin/partials/index.html.erb +131 -0
  64. data/app/views/active_canvas/admin/settings/show.html.erb +1864 -0
  65. data/app/views/active_canvas/pages/no_homepage.html.erb +45 -0
  66. data/app/views/active_canvas/pages/show.html.erb +113 -0
  67. data/app/views/layouts/active_canvas/admin/application.html.erb +960 -0
  68. data/app/views/layouts/active_canvas/admin/editor.html.erb +826 -0
  69. data/app/views/layouts/active_canvas/application.html.erb +55 -0
  70. data/config/routes.rb +48 -0
  71. data/db/migrate/20260202000001_create_active_canvas_tables.rb +113 -0
  72. data/db/migrate/20260202000002_create_active_canvas_ai_models.rb +26 -0
  73. data/lib/active_canvas/configuration.rb +232 -0
  74. data/lib/active_canvas/engine.rb +44 -0
  75. data/lib/active_canvas/version.rb +3 -0
  76. data/lib/active_canvas.rb +26 -0
  77. data/lib/generators/active_canvas/install/install_generator.rb +263 -0
  78. data/lib/generators/active_canvas/install/templates/initializer.rb.tt +163 -0
  79. data/lib/tasks/active_canvas_tasks.rake +69 -0
  80. metadata +150 -0
@@ -0,0 +1,256 @@
1
+ module ActiveCanvas
2
+ module Admin
3
+ class SettingsController < ApplicationController
4
+ def show
5
+ @active_tab = params[:tab] || "general"
6
+ @homepage_page_id = Setting.homepage_page_id
7
+ @css_framework = Setting.css_framework
8
+ @global_css = Setting.global_css
9
+ @global_js = Setting.global_js
10
+ @pages = Page.published.order(:title)
11
+
12
+ # Tailwind settings
13
+ @tailwind_config = Setting.tailwind_config_js
14
+ @tailwind_available = ActiveCanvas::TailwindCompiler.available?
15
+ @tailwind_compiled_mode = Setting.tailwind_compiled_mode?
16
+
17
+ # AI settings - use masked values for display
18
+ @ai_openai_key = Setting.masked_api_key("ai_openai_api_key")
19
+ @ai_anthropic_key = Setting.masked_api_key("ai_anthropic_api_key")
20
+ @ai_openrouter_key = Setting.masked_api_key("ai_openrouter_api_key")
21
+ @ai_openai_configured = Setting.api_key_configured?("ai_openai_api_key")
22
+ @ai_anthropic_configured = Setting.api_key_configured?("ai_anthropic_api_key")
23
+ @ai_openrouter_configured = Setting.api_key_configured?("ai_openrouter_api_key")
24
+ @ai_default_text_model = Setting.ai_default_text_model
25
+ @ai_default_image_model = Setting.ai_default_image_model
26
+ @ai_text_enabled = Setting.ai_text_enabled?
27
+ @ai_image_enabled = Setting.ai_image_enabled?
28
+ @ai_screenshot_enabled = Setting.ai_screenshot_enabled?
29
+
30
+ # Model sync info
31
+ @ai_models_synced = AiModels.models_synced?
32
+ @ai_models_last_synced = AiModels.last_synced_at
33
+ @ai_models_count = AiModel.count if @ai_models_synced
34
+ @ai_default_vision_model = Setting.ai_default_vision_model
35
+ @ai_connection_mode = Setting.ai_connection_mode
36
+ @ai_text_models = AiModels.all_text_models
37
+ @ai_image_models = AiModels.all_image_models
38
+ @ai_vision_models = AiModels.all_vision_models
39
+
40
+ # Models tab - models from configured providers only
41
+ if @active_tab == "models"
42
+ configured_providers = AiConfiguration.configured_providers
43
+ @all_models_by_provider = AiModel
44
+ .where(provider: configured_providers)
45
+ .order(:provider, :model_type, :name)
46
+ .group_by(&:provider)
47
+ end
48
+ end
49
+
50
+ def update
51
+ Setting.homepage_page_id = params[:homepage_page_id]
52
+
53
+ redirect_to admin_settings_path, notice: "Settings saved successfully."
54
+ end
55
+
56
+ def update_global_css
57
+ Setting.global_css = params[:global_css]
58
+
59
+ respond_to do |format|
60
+ format.html { redirect_to admin_settings_path(tab: "styles"), notice: "Global CSS saved." }
61
+ format.json { render json: { success: true, message: "Global CSS saved." } }
62
+ end
63
+ end
64
+
65
+ def update_global_js
66
+ Setting.global_js = params[:global_js]
67
+
68
+ respond_to do |format|
69
+ format.html { redirect_to admin_settings_path(tab: "scripts"), notice: "Global JavaScript saved." }
70
+ format.json { render json: { success: true, message: "Global JavaScript saved." } }
71
+ end
72
+ end
73
+
74
+ def update_ai
75
+ # API Keys - only update if a new value is provided (not empty, not masked)
76
+ update_api_key("ai_openai_api_key", params[:ai_openai_api_key])
77
+ update_api_key("ai_anthropic_api_key", params[:ai_anthropic_api_key])
78
+ update_api_key("ai_openrouter_api_key", params[:ai_openrouter_api_key])
79
+
80
+ # Default models
81
+ Setting.ai_default_text_model = params[:ai_default_text_model] if params.key?(:ai_default_text_model)
82
+ Setting.ai_default_image_model = params[:ai_default_image_model] if params.key?(:ai_default_image_model)
83
+ Setting.ai_default_vision_model = params[:ai_default_vision_model] if params.key?(:ai_default_vision_model)
84
+
85
+ # Connection mode
86
+ Setting.ai_connection_mode = params[:ai_connection_mode] if params.key?(:ai_connection_mode)
87
+
88
+ # Feature toggles
89
+ Setting.ai_text_enabled = params[:ai_text_enabled] == "1"
90
+ Setting.ai_image_enabled = params[:ai_image_enabled] == "1"
91
+ Setting.ai_screenshot_enabled = params[:ai_screenshot_enabled] == "1"
92
+
93
+ respond_to do |format|
94
+ format.html { redirect_to admin_settings_path(tab: "ai"), notice: "AI settings saved." }
95
+ format.json { render json: { success: true, message: "AI settings saved." } }
96
+ end
97
+ end
98
+
99
+ def sync_ai_models
100
+ unless AiConfiguration.configured?
101
+ respond_to do |format|
102
+ format.html { redirect_to admin_settings_path(tab: "ai"), alert: "Please configure at least one API key first." }
103
+ format.json { render json: { success: false, error: "Not configured" }, status: :unprocessable_entity }
104
+ end
105
+ return
106
+ end
107
+
108
+ begin
109
+ count = AiModels.refresh!
110
+
111
+ respond_to do |format|
112
+ format.html { redirect_to admin_settings_path(tab: "ai"), notice: "Synced #{count} models from providers." }
113
+ format.json { render json: { success: true, count: count, message: "Synced #{count} models." } }
114
+ end
115
+ rescue => e
116
+ Rails.logger.error "AI Model Sync Error: #{e.message}"
117
+ respond_to do |format|
118
+ format.html { redirect_to admin_settings_path(tab: "ai"), alert: "Failed to sync models: #{e.message}" }
119
+ format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
120
+ end
121
+ end
122
+ end
123
+
124
+ def toggle_ai_model
125
+ model = AiModel.find(params[:model_id])
126
+ model.update!(active: !model.active)
127
+
128
+ respond_to do |format|
129
+ format.html { redirect_to admin_settings_path(tab: "models"), notice: "#{model.display_name} #{model.active? ? 'activated' : 'deactivated'}." }
130
+ format.json { render json: { success: true, active: model.active, model_id: model.id } }
131
+ end
132
+ rescue ActiveRecord::RecordNotFound
133
+ respond_to do |format|
134
+ format.html { redirect_to admin_settings_path(tab: "models"), alert: "Model not found." }
135
+ format.json { render json: { success: false, error: "Model not found" }, status: :not_found }
136
+ end
137
+ end
138
+
139
+ def create_ai_model
140
+ model = AiModel.new(
141
+ model_id: params[:model_id],
142
+ provider: params[:provider],
143
+ model_type: params[:model_type],
144
+ name: params[:name],
145
+ context_window: params[:context_window].presence,
146
+ max_tokens: params[:max_tokens].presence,
147
+ supports_functions: params[:supports_functions] == "1",
148
+ active: params[:active] != "0",
149
+ input_modalities: Array(params[:input_modalities]).reject(&:blank?),
150
+ output_modalities: Array(params[:output_modalities]).reject(&:blank?)
151
+ )
152
+
153
+ if model.save
154
+ respond_to do |format|
155
+ format.html { redirect_to admin_settings_path(tab: "models"), notice: "Model '#{model.display_name}' added." }
156
+ format.json { render json: { success: true, model: model.as_json_for_editor } }
157
+ end
158
+ else
159
+ respond_to do |format|
160
+ format.html { redirect_to admin_settings_path(tab: "models"), alert: model.errors.full_messages.to_sentence }
161
+ format.json { render json: { success: false, errors: model.errors.full_messages }, status: :unprocessable_entity }
162
+ end
163
+ end
164
+ end
165
+
166
+ def destroy_ai_model
167
+ model = AiModel.find(params[:model_id])
168
+ model.destroy!
169
+
170
+ respond_to do |format|
171
+ format.html { redirect_to admin_settings_path(tab: "models"), notice: "Model '#{model.display_name}' removed." }
172
+ format.json { render json: { success: true } }
173
+ end
174
+ rescue ActiveRecord::RecordNotFound
175
+ respond_to do |format|
176
+ format.html { redirect_to admin_settings_path(tab: "models"), alert: "Model not found." }
177
+ format.json { render json: { success: false, error: "Model not found" }, status: :not_found }
178
+ end
179
+ end
180
+
181
+ def bulk_toggle_ai_models
182
+ action = params[:action_type]
183
+ scope = params[:scope]
184
+ provider = params[:provider]
185
+
186
+ models = AiModel.all
187
+ models = models.where(provider: provider) if provider.present?
188
+ models = models.where(model_type: scope) if scope.present? && scope != "all"
189
+
190
+ case action
191
+ when "activate"
192
+ count = models.update_all(active: true)
193
+ message = "Activated #{count} models."
194
+ when "deactivate"
195
+ count = models.update_all(active: false)
196
+ message = "Deactivated #{count} models."
197
+ else
198
+ message = "Invalid action."
199
+ end
200
+
201
+ respond_to do |format|
202
+ format.html { redirect_to admin_settings_path(tab: "models"), notice: message }
203
+ format.json { render json: { success: true, count: count, message: message } }
204
+ end
205
+ end
206
+
207
+ def update_tailwind_config
208
+ Setting.tailwind_config = params[:tailwind_config]
209
+
210
+ respond_to do |format|
211
+ format.html { redirect_to admin_settings_path(tab: "styles"), notice: "Tailwind configuration saved." }
212
+ format.json { render json: { success: true, message: "Tailwind configuration saved." } }
213
+ end
214
+ end
215
+
216
+ private
217
+
218
+ def update_api_key(key, value)
219
+ return if value.blank?
220
+ return if value.start_with?("****") # Masked value, don't update
221
+
222
+ Setting.set(key, value)
223
+ end
224
+
225
+ public
226
+
227
+ def recompile_tailwind
228
+ unless ActiveCanvas::TailwindCompiler.available?
229
+ respond_to do |format|
230
+ format.html { redirect_to admin_settings_path(tab: "styles"), alert: "tailwindcss-ruby gem is not installed." }
231
+ format.json { render json: { success: false, error: "tailwindcss-ruby gem is not installed." }, status: :unprocessable_entity }
232
+ end
233
+ return
234
+ end
235
+
236
+ unless Setting.css_framework == "tailwind"
237
+ respond_to do |format|
238
+ format.html { redirect_to admin_settings_path(tab: "styles"), alert: "Tailwind is not the selected CSS framework." }
239
+ format.json { render json: { success: false, error: "Tailwind is not the selected CSS framework." }, status: :unprocessable_entity }
240
+ end
241
+ return
242
+ end
243
+
244
+ pages = Page.where.not(content: [nil, ""])
245
+ pages.find_each do |page|
246
+ CompileTailwindJob.perform_later(page.id)
247
+ end
248
+
249
+ respond_to do |format|
250
+ format.html { redirect_to admin_settings_path(tab: "styles"), notice: "Queued #{pages.count} pages for Tailwind compilation." }
251
+ format.json { render json: { success: true, count: pages.count, message: "Queued #{pages.count} pages for compilation." } }
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,20 @@
1
+ module ActiveCanvas
2
+ class ApplicationController < ActiveCanvas.config.public_parent_controller.constantize
3
+ include ActiveCanvas::CurrentUser
4
+
5
+ before_action :active_canvas_authenticate_public
6
+
7
+ private
8
+
9
+ def active_canvas_authenticate_public
10
+ auth = ActiveCanvas.config.authenticate_public
11
+ return unless auth
12
+
13
+ if auth.is_a?(Symbol)
14
+ send(auth)
15
+ elsif auth.respond_to?(:call)
16
+ instance_exec(&auth)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ module ActiveCanvas
2
+ class PagesController < ApplicationController
3
+ def home
4
+ @page = Setting.homepage
5
+
6
+ if @page
7
+ render :show
8
+ else
9
+ render :no_homepage
10
+ end
11
+ end
12
+
13
+ def show
14
+ @page = Page.published.find_by(slug: params[:slug])
15
+ raise ActionController::RoutingError, "Not Found" unless @page
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ module ActiveCanvas
2
+ module CurrentUser
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
7
+ def active_canvas_current_user
8
+ method_name = ActiveCanvas.config.current_user_method
9
+ respond_to?(method_name, true) ? send(method_name) : nil
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,75 @@
1
+ module ActiveCanvas
2
+ module RateLimitable
3
+ extend ActiveSupport::Concern
4
+
5
+ class RateLimitExceeded < StandardError; end
6
+
7
+ included do
8
+ rescue_from RateLimitExceeded, with: :render_rate_limit_exceeded
9
+ end
10
+
11
+ private
12
+
13
+ def check_rate_limit(namespace: "default", limit: nil, window: 1.minute)
14
+ limit ||= ActiveCanvas.config.ai_rate_limit_per_minute
15
+ cache_key = rate_limit_cache_key(namespace)
16
+
17
+ count = increment_rate_limit(cache_key, window)
18
+
19
+ if count > limit
20
+ Rails.logger.warn "[ActiveCanvas] Rate limit exceeded for #{request.remote_ip} (#{count}/#{limit} in #{window})"
21
+ raise RateLimitExceeded, "Rate limit exceeded. Please try again later."
22
+ end
23
+ end
24
+
25
+ def rate_limit_cache_key(namespace)
26
+ "active_canvas:rate_limit:#{namespace}:#{request.remote_ip}"
27
+ end
28
+
29
+ def increment_rate_limit(cache_key, window)
30
+ if Rails.cache.respond_to?(:increment)
31
+ # Redis or other cache with atomic increment
32
+ count = Rails.cache.increment(cache_key, 1, expires_in: window, raw: true)
33
+ count.to_i
34
+ else
35
+ # Fallback for memory store or other caches without atomic increment
36
+ current = Rails.cache.read(cache_key).to_i
37
+ Rails.cache.write(cache_key, current + 1, expires_in: window)
38
+ current + 1
39
+ end
40
+ end
41
+
42
+ def render_rate_limit_exceeded(exception)
43
+ respond_to do |format|
44
+ format.html { render plain: exception.message, status: :too_many_requests }
45
+ format.json { render json: { error: exception.message }, status: :too_many_requests }
46
+ end
47
+ end
48
+
49
+ # Verify request origin for CSRF protection on streaming endpoints
50
+ def verify_request_origin
51
+ # Same-origin requests don't have an Origin header
52
+ return if request.origin.blank?
53
+
54
+ # Build list of allowed origins
55
+ allowed_origins = []
56
+
57
+ # Current host
58
+ host = request.host
59
+ port = request.port
60
+
61
+ if request.ssl?
62
+ allowed_origins << "https://#{host}"
63
+ allowed_origins << "https://#{host}:#{port}" unless port == 443
64
+ else
65
+ allowed_origins << "http://#{host}"
66
+ allowed_origins << "http://#{host}:#{port}" unless port == 80
67
+ end
68
+
69
+ unless allowed_origins.include?(request.origin)
70
+ Rails.logger.warn "[ActiveCanvas] Origin verification failed: #{request.origin} not in #{allowed_origins}"
71
+ render json: { error: "Invalid request origin" }, status: :forbidden
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,39 @@
1
+ module ActiveCanvas
2
+ module TailwindCompilation
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
7
+ # Compiles Tailwind CSS if the framework is active and content changed.
8
+ # Callers must pass a block that performs the actual compilation and
9
+ # persistence, and returns the compiled CSS string.
10
+ #
11
+ # compile_tailwind_if_needed(content_changed) do
12
+ # css = ActiveCanvas::TailwindCompiler.compile_for_page(@page)
13
+ # @page.update_columns(compiled_tailwind_css: css, tailwind_compiled_at: Time.current)
14
+ # css
15
+ # end
16
+ #
17
+ def compile_tailwind_if_needed(content_changed)
18
+ return { compiled: false, reason: "content_unchanged" } unless content_changed
19
+ return { compiled: false, reason: "not_tailwind" } unless Setting.css_framework == "tailwind"
20
+ return { compiled: false, reason: "gem_not_available" } unless ActiveCanvas::TailwindCompiler.available?
21
+
22
+ begin
23
+ start_time = Time.current
24
+ compiled_css = yield
25
+ elapsed_ms = ((Time.current - start_time) * 1000).round
26
+
27
+ {
28
+ compiled: true,
29
+ success: true,
30
+ css_size: compiled_css.bytesize,
31
+ elapsed_ms: elapsed_ms
32
+ }
33
+ rescue ActiveCanvas::TailwindCompiler::CompilationError => e
34
+ Rails.logger.error "[ActiveCanvas] Tailwind compilation failed: #{e.message}"
35
+ { compiled: true, success: false, error: e.message }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveCanvas
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveCanvas
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,64 @@
1
+ module ActiveCanvas
2
+ class CompileTailwindJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ retry_on ActiveCanvas::TailwindCompiler::CompilationError, wait: :polynomially_longer, attempts: 3
6
+
7
+ LOG_PREFIX = "[ActiveCanvas::CompileTailwindJob]".freeze
8
+
9
+ def perform(page_id)
10
+ log_info "Job started for page ##{page_id}"
11
+
12
+ page = Page.find_by(id: page_id)
13
+ unless page
14
+ log_warn "Page ##{page_id} not found, skipping"
15
+ return
16
+ end
17
+
18
+ unless ActiveCanvas::TailwindCompiler.available?
19
+ log_info "Skipping: tailwindcss-ruby gem not available"
20
+ return
21
+ end
22
+
23
+ unless Setting.css_framework == "tailwind"
24
+ log_info "Skipping: Tailwind not selected as framework (current: #{Setting.css_framework})"
25
+ return
26
+ end
27
+
28
+ log_info "Compiling Tailwind CSS for page ##{page.id} (#{page.title})"
29
+ start_time = Time.current
30
+
31
+ compiled_css = ActiveCanvas::TailwindCompiler.compile_for_page(page)
32
+
33
+ page.update_columns(
34
+ compiled_tailwind_css: compiled_css,
35
+ tailwind_compiled_at: Time.current
36
+ )
37
+
38
+ elapsed = ((Time.current - start_time) * 1000).round(2)
39
+ log_info "Job completed for page ##{page.id} in #{elapsed}ms (CSS size: #{compiled_css.bytesize} bytes)"
40
+
41
+ rescue ActiveCanvas::TailwindCompiler::CompilationError => e
42
+ log_error "Compilation failed for page ##{page_id}: #{e.message}"
43
+ raise
44
+ rescue => e
45
+ log_error "Unexpected error for page ##{page_id}: #{e.class.name}: #{e.message}"
46
+ log_error e.backtrace.first(5).join("\n")
47
+ raise
48
+ end
49
+
50
+ private
51
+
52
+ def log_info(message)
53
+ Rails.logger.info "#{LOG_PREFIX} #{message}"
54
+ end
55
+
56
+ def log_warn(message)
57
+ Rails.logger.warn "#{LOG_PREFIX} #{message}"
58
+ end
59
+
60
+ def log_error(message)
61
+ Rails.logger.error "#{LOG_PREFIX} #{message}"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,6 @@
1
+ module ActiveCanvas
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,136 @@
1
+ module ActiveCanvas
2
+ class AiModel < ApplicationRecord
3
+ validates :model_id, presence: true, uniqueness: true
4
+ validates :provider, presence: true
5
+
6
+ serialize :input_modalities, coder: JSON
7
+ serialize :output_modalities, coder: JSON
8
+
9
+ scope :active, -> { where(active: true) }
10
+ scope :for_provider, ->(provider) { where(provider: provider) }
11
+ scope :with_functions, -> { where(supports_functions: true) }
12
+ scope :by_family, ->(family) { where(family: family) }
13
+
14
+ # Modality-based scopes
15
+ scope :with_text_output, -> { where("output_modalities LIKE ?", '%"text"%') }
16
+ scope :with_image_output, -> { where("output_modalities LIKE ?", '%"image"%') }
17
+ scope :with_text_input, -> { where("input_modalities LIKE ?", '%"text"%') }
18
+ scope :with_image_input, -> { where("input_modalities LIKE ?", '%"image"%') }
19
+
20
+ # Convenience scopes matching old names
21
+ scope :text_models, -> { with_text_output }
22
+ scope :image_models, -> { with_text_input.with_image_output }
23
+ scope :vision_models, -> { with_text_input.with_image_input.with_text_output }
24
+
25
+ # Keep these for display grouping in settings
26
+ scope :chat_models, -> { where(model_type: "chat") }
27
+ scope :embedding_models, -> { where(model_type: "embedding") }
28
+ scope :audio_models, -> { where(model_type: "audio") }
29
+
30
+ MODALITY_MAP = {
31
+ "chat" => { input: %w[text], output: %w[text] },
32
+ "image" => { input: %w[text], output: %w[image] },
33
+ "embedding" => { input: %w[text], output: %w[embedding] },
34
+ "audio" => { input: %w[text], output: %w[audio] }
35
+ }.freeze
36
+
37
+ class << self
38
+ def refresh_from_ruby_llm!
39
+ raise_if_ruby_llm_not_available!
40
+
41
+ RubyLLM.models.refresh!
42
+
43
+ models_data = RubyLLM.models.all
44
+
45
+ imported_count = 0
46
+ models_data.each do |model|
47
+ record = find_or_initialize_by(model_id: model.id)
48
+ is_new_record = record.new_record?
49
+
50
+ modalities = derive_modalities(model.type, model.supports_vision?)
51
+
52
+ record.assign_attributes(
53
+ provider: model.provider,
54
+ model_type: model.type,
55
+ name: model.name || model.id,
56
+ family: model.family,
57
+ context_window: model.context_window,
58
+ max_tokens: model.max_tokens,
59
+ input_modalities: modalities[:input],
60
+ output_modalities: modalities[:output],
61
+ supports_functions: model.supports_functions? || false,
62
+ input_price_per_million: model.input_price_per_million,
63
+ output_price_per_million: model.output_price_per_million
64
+ )
65
+
66
+ record.active = true if is_new_record
67
+
68
+ if record.save
69
+ imported_count += 1
70
+ else
71
+ Rails.logger.warn "Failed to save AI model #{model.id}: #{record.errors.full_messages.join(', ')}"
72
+ end
73
+ end
74
+
75
+ imported_count
76
+ end
77
+
78
+ def text_models_for_providers(providers)
79
+ active.text_models.where(provider: providers).order(:name)
80
+ end
81
+
82
+ def image_models_for_providers(providers)
83
+ active.image_models.where(provider: providers).order(:name)
84
+ end
85
+
86
+ def vision_models_for_providers(providers)
87
+ active.vision_models.where(provider: providers).order(:name)
88
+ end
89
+
90
+ private
91
+
92
+ def derive_modalities(type, vision)
93
+ base = MODALITY_MAP.fetch(type.to_s, { input: %w[text], output: %w[text] })
94
+ input = vision ? (base[:input] | %w[image]) : base[:input]
95
+ { input: input, output: base[:output] }
96
+ end
97
+
98
+ def raise_if_ruby_llm_not_available!
99
+ # return if AiConfiguration.ruby_llm_available?
100
+
101
+ # raise LoadError, "RubyLLM gem is not available. Add 'ruby_llm' to your Gemfile."
102
+ end
103
+ end
104
+
105
+ def display_name
106
+ name.presence || model_id
107
+ end
108
+
109
+ def supports_vision?
110
+ input_modalities&.include?("image")
111
+ end
112
+
113
+ def price_info
114
+ return nil if input_price_per_million.nil? && output_price_per_million.nil?
115
+
116
+ parts = []
117
+ parts << "$#{input_price_per_million}/M in" if input_price_per_million
118
+ parts << "$#{output_price_per_million}/M out" if output_price_per_million
119
+ parts.join(" / ")
120
+ end
121
+
122
+ def as_json_for_editor
123
+ {
124
+ id: model_id,
125
+ name: display_name,
126
+ provider: provider,
127
+ type: model_type,
128
+ input_modalities: input_modalities,
129
+ output_modalities: output_modalities,
130
+ supports_functions: supports_functions,
131
+ context_window: context_window,
132
+ max_tokens: max_tokens
133
+ }
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveCanvas
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end