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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +318 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/active_canvas/editor/ai_panel.js +1607 -0
- data/app/assets/javascripts/active_canvas/editor/asset_manager.js +498 -0
- data/app/assets/javascripts/active_canvas/editor/blocks.js +1083 -0
- data/app/assets/javascripts/active_canvas/editor/code_panel.js +572 -0
- data/app/assets/javascripts/active_canvas/editor/component_toolbar.js +394 -0
- data/app/assets/javascripts/active_canvas/editor/panels.js +460 -0
- data/app/assets/javascripts/active_canvas/editor/utils.js +56 -0
- data/app/assets/javascripts/active_canvas/editor.js +295 -0
- data/app/assets/stylesheets/active_canvas/application.css +15 -0
- data/app/assets/stylesheets/active_canvas/editor.css +2929 -0
- data/app/controllers/active_canvas/admin/ai_controller.rb +181 -0
- data/app/controllers/active_canvas/admin/application_controller.rb +56 -0
- data/app/controllers/active_canvas/admin/media_controller.rb +61 -0
- data/app/controllers/active_canvas/admin/page_types_controller.rb +57 -0
- data/app/controllers/active_canvas/admin/page_versions_controller.rb +23 -0
- data/app/controllers/active_canvas/admin/pages_controller.rb +133 -0
- data/app/controllers/active_canvas/admin/partials_controller.rb +88 -0
- data/app/controllers/active_canvas/admin/settings_controller.rb +256 -0
- data/app/controllers/active_canvas/application_controller.rb +20 -0
- data/app/controllers/active_canvas/pages_controller.rb +18 -0
- data/app/controllers/concerns/active_canvas/current_user.rb +12 -0
- data/app/controllers/concerns/active_canvas/rate_limitable.rb +75 -0
- data/app/controllers/concerns/active_canvas/tailwind_compilation.rb +39 -0
- data/app/helpers/active_canvas/application_helper.rb +4 -0
- data/app/jobs/active_canvas/application_job.rb +4 -0
- data/app/jobs/active_canvas/compile_tailwind_job.rb +64 -0
- data/app/mailers/active_canvas/application_mailer.rb +6 -0
- data/app/models/active_canvas/ai_model.rb +136 -0
- data/app/models/active_canvas/application_record.rb +5 -0
- data/app/models/active_canvas/media.rb +141 -0
- data/app/models/active_canvas/page.rb +85 -0
- data/app/models/active_canvas/page_type.rb +22 -0
- data/app/models/active_canvas/page_version.rb +80 -0
- data/app/models/active_canvas/partial.rb +73 -0
- data/app/models/active_canvas/setting.rb +292 -0
- data/app/services/active_canvas/ai_configuration.rb +40 -0
- data/app/services/active_canvas/ai_models.rb +128 -0
- data/app/services/active_canvas/ai_service.rb +289 -0
- data/app/services/active_canvas/content_sanitizer.rb +112 -0
- data/app/services/active_canvas/tailwind_compiler.rb +156 -0
- data/app/views/active_canvas/admin/media/index.html.erb +401 -0
- data/app/views/active_canvas/admin/media/show.html.erb +297 -0
- data/app/views/active_canvas/admin/page_types/_form.html.erb +25 -0
- data/app/views/active_canvas/admin/page_types/edit.html.erb +13 -0
- data/app/views/active_canvas/admin/page_types/index.html.erb +29 -0
- data/app/views/active_canvas/admin/page_types/new.html.erb +9 -0
- data/app/views/active_canvas/admin/page_types/show.html.erb +18 -0
- data/app/views/active_canvas/admin/page_versions/show.html.erb +469 -0
- data/app/views/active_canvas/admin/pages/_form.html.erb +62 -0
- data/app/views/active_canvas/admin/pages/content.html.erb +139 -0
- data/app/views/active_canvas/admin/pages/edit.html.erb +335 -0
- data/app/views/active_canvas/admin/pages/editor.html.erb +710 -0
- data/app/views/active_canvas/admin/pages/index.html.erb +149 -0
- data/app/views/active_canvas/admin/pages/new.html.erb +19 -0
- data/app/views/active_canvas/admin/pages/show.html.erb +258 -0
- data/app/views/active_canvas/admin/pages/versions.html.erb +333 -0
- data/app/views/active_canvas/admin/partials/edit.html.erb +182 -0
- data/app/views/active_canvas/admin/partials/editor.html.erb +703 -0
- data/app/views/active_canvas/admin/partials/index.html.erb +131 -0
- data/app/views/active_canvas/admin/settings/show.html.erb +1864 -0
- data/app/views/active_canvas/pages/no_homepage.html.erb +45 -0
- data/app/views/active_canvas/pages/show.html.erb +113 -0
- data/app/views/layouts/active_canvas/admin/application.html.erb +960 -0
- data/app/views/layouts/active_canvas/admin/editor.html.erb +826 -0
- data/app/views/layouts/active_canvas/application.html.erb +55 -0
- data/config/routes.rb +48 -0
- data/db/migrate/20260202000001_create_active_canvas_tables.rb +113 -0
- data/db/migrate/20260202000002_create_active_canvas_ai_models.rb +26 -0
- data/lib/active_canvas/configuration.rb +232 -0
- data/lib/active_canvas/engine.rb +44 -0
- data/lib/active_canvas/version.rb +3 -0
- data/lib/active_canvas.rb +26 -0
- data/lib/generators/active_canvas/install/install_generator.rb +263 -0
- data/lib/generators/active_canvas/install/templates/initializer.rb.tt +163 -0
- data/lib/tasks/active_canvas_tasks.rake +69 -0
- 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,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,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,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
|