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,141 @@
1
+ module ActiveCanvas
2
+ class Media < ApplicationRecord
3
+ # Use configured storage service if specified, otherwise default
4
+ # Host app can set ActiveCanvas.config.storage_service = :public
5
+ # and define a 'public' service in config/storage.yml for cloud storage
6
+ has_one_attached :file, service: (ActiveCanvas.config.storage_service rescue nil) do |attachable|
7
+ attachable.variant :thumb, resize_to_limit: [200, 200]
8
+ end
9
+
10
+ serialize :metadata, coder: JSON
11
+
12
+ validates :filename, presence: true
13
+ validates :file, presence: true, on: :create
14
+
15
+ validate :acceptable_file, on: :create
16
+
17
+ before_save :set_file_attributes, if: -> { file.attached? && file.blob.present? }
18
+ after_commit :make_blob_public, on: [:create, :update], if: :should_make_public?
19
+
20
+ scope :images, -> { where(content_type: ActiveCanvas.config.allowed_content_types) }
21
+ scope :recent, -> { order(created_at: :desc) }
22
+
23
+ def metadata
24
+ super || {}
25
+ end
26
+
27
+ def url
28
+ return nil unless file.attached?
29
+
30
+ # If public_uploads is enabled and blob service supports public URLs
31
+ if ActiveCanvas.config.public_uploads &&
32
+ file.blob.service.respond_to?(:public?) &&
33
+ file.blob.service.public?
34
+ file.url
35
+ else
36
+ # Use signed URL with expiration for better security
37
+ Rails.application.routes.url_helpers.rails_blob_url(
38
+ file,
39
+ expires_in: 1.hour,
40
+ only_path: true
41
+ )
42
+ end
43
+ rescue ArgumentError
44
+ # Fallback for services that don't support expires_in
45
+ Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true)
46
+ end
47
+
48
+ def public_url
49
+ return nil unless file.attached?
50
+
51
+ # Returns the direct public URL for cloud storage
52
+ # or the rails blob URL for local storage
53
+ if file.blob.service.respond_to?(:url)
54
+ file.url(expires_in: nil) rescue file.url
55
+ else
56
+ url
57
+ end
58
+ end
59
+
60
+ def as_json_for_editor
61
+ {
62
+ id: id,
63
+ src: url,
64
+ name: filename,
65
+ type: content_type,
66
+ width: metadata["width"],
67
+ height: metadata["height"]
68
+ }
69
+ end
70
+
71
+ private
72
+
73
+ def set_file_attributes
74
+ self.content_type = file.blob.content_type
75
+ self.byte_size = file.blob.byte_size
76
+ self.filename = file.blob.filename.to_s if filename.blank?
77
+
78
+ # Store blob metadata (dimensions for images) if available
79
+ if file.blob.metadata.present?
80
+ self.metadata = file.blob.metadata
81
+ end
82
+ end
83
+
84
+ def acceptable_file
85
+ return unless file.attached?
86
+
87
+ content_type = file.content_type
88
+ config = ActiveCanvas.config
89
+
90
+ # Check against dangerous content types first
91
+ if ActiveCanvas::Configuration::DANGEROUS_CONTENT_TYPES.include?(content_type)
92
+ errors.add(:file, "type is not allowed for security reasons")
93
+ return
94
+ end
95
+
96
+ # Special handling for SVG
97
+ if content_type == "image/svg+xml"
98
+ unless config.allow_svg_uploads
99
+ errors.add(:file, "SVG uploads are disabled for security reasons. Contact your administrator if you need to upload SVG files.")
100
+ return
101
+ end
102
+ end
103
+
104
+ # Check against allowed content types
105
+ unless config.effective_allowed_content_types.include?(content_type)
106
+ allowed = config.effective_allowed_content_types.map { |t| t.split("/").last.upcase }.join(", ")
107
+ errors.add(:file, "type is not allowed. Allowed types: #{allowed}")
108
+ end
109
+
110
+ if file.blob.byte_size > config.max_upload_size
111
+ errors.add(:file, "is too large (maximum is #{config.max_upload_size / 1.megabyte}MB)")
112
+ end
113
+ end
114
+
115
+ def should_make_public?
116
+ file.attached? && ActiveCanvas.config.public_uploads
117
+ end
118
+
119
+ def make_blob_public
120
+ return unless file.attached? && file.blob.present?
121
+
122
+ blob = file.blob
123
+
124
+ # For S3 and compatible services, set the blob to public-read ACL
125
+ if blob.service.respond_to?(:bucket)
126
+ begin
127
+ # AWS S3
128
+ if blob.service.respond_to?(:object_for)
129
+ object = blob.service.object_for(blob.key)
130
+ object.acl.put(acl: "public-read") if object.respond_to?(:acl)
131
+ Rails.logger.info "ActiveCanvas: Set public-read ACL for #{blob.key}"
132
+ end
133
+ rescue Aws::S3::Errors::AccessDenied => e
134
+ Rails.logger.warn "ActiveCanvas: Access denied setting public ACL. Ensure your S3 bucket allows public ACLs: #{e.message}"
135
+ rescue => e
136
+ Rails.logger.warn "ActiveCanvas: Could not set public ACL for blob #{blob.key}: #{e.message}"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,85 @@
1
+ module ActiveCanvas
2
+ class Page < ApplicationRecord
3
+ belongs_to :page_type
4
+ has_many :versions, class_name: "ActiveCanvas::PageVersion", dependent: :destroy
5
+
6
+ validates :title, presence: true
7
+ validates :slug, uniqueness: true, allow_blank: true
8
+
9
+ before_save :set_default_slug
10
+ before_save :normalize_slug
11
+ before_save :sanitize_content_if_enabled
12
+ after_update :create_version_if_content_changed
13
+
14
+ scope :published, -> { where(published: true) }
15
+ scope :draft, -> { where(published: false) }
16
+
17
+ # Thread-local storage for tracking who made the change
18
+ thread_cattr_accessor :current_editor
19
+
20
+ def to_param
21
+ id&.to_s
22
+ end
23
+
24
+ def rendered_content
25
+ content.to_s.html_safe
26
+ end
27
+
28
+ def current_version_number
29
+ versions.maximum(:version_number) || 0
30
+ end
31
+
32
+ # Header/footer display (with fallback for when columns don't exist yet)
33
+ def show_header?
34
+ return true unless self.class.column_names.include?("show_header")
35
+ show_header != false
36
+ end
37
+
38
+ def show_footer?
39
+ return true unless self.class.column_names.include?("show_footer")
40
+ show_footer != false
41
+ end
42
+
43
+ private
44
+
45
+ def set_default_slug
46
+ self.slug = "active_canvas_id_#{id}" if slug.blank? && persisted?
47
+ end
48
+
49
+ def normalize_slug
50
+ self.slug = slug.parameterize if slug.present?
51
+ end
52
+
53
+ def sanitize_content_if_enabled
54
+ return unless ActiveCanvas.config.sanitize_content
55
+
56
+ if content_changed?
57
+ self.content = ContentSanitizer.sanitize_html(content)
58
+ end
59
+
60
+ if content_css_changed?
61
+ self.content_css = ContentSanitizer.sanitize_css(content_css)
62
+ end
63
+ end
64
+
65
+ def create_version_if_content_changed
66
+ return unless saved_change_to_content? || saved_change_to_content_css?
67
+
68
+ versions.create!(
69
+ content_before: content_before_last_save,
70
+ content_after: content,
71
+ css_before: content_css_before_last_save,
72
+ css_after: content_css,
73
+ changed_by: self.class.current_editor,
74
+ change_summary: generate_change_summary
75
+ )
76
+ end
77
+
78
+ def generate_change_summary
79
+ changes = []
80
+ changes << "content updated" if saved_change_to_content?
81
+ changes << "CSS updated" if saved_change_to_content_css?
82
+ changes.join(", ").presence || "Updated"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,22 @@
1
+ module ActiveCanvas
2
+ class PageType < ApplicationRecord
3
+ has_many :pages, dependent: :restrict_with_error
4
+
5
+ validates :name, presence: true
6
+ validates :key, presence: true, uniqueness: true
7
+
8
+ before_validation :generate_key, on: :create
9
+
10
+ def self.default
11
+ find_or_create_by!(key: "page") do |pt|
12
+ pt.name = "Page"
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def generate_key
19
+ self.key ||= name&.parameterize&.underscore
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,80 @@
1
+ module ActiveCanvas
2
+ class PageVersion < ApplicationRecord
3
+ belongs_to :page
4
+
5
+ validates :version_number, presence: true, uniqueness: { scope: :page_id }
6
+
7
+ before_validation :set_version_number, on: :create
8
+ before_create :compute_diff
9
+ before_create :set_content_sizes
10
+
11
+ scope :recent, -> { order(version_number: :desc) }
12
+ scope :oldest_first, -> { order(version_number: :asc) }
13
+
14
+ def content_changed?
15
+ content_before != content_after
16
+ end
17
+
18
+ def css_changed?
19
+ css_before != css_after
20
+ end
21
+
22
+ def size_difference
23
+ (content_size_after || 0) - (content_size_before || 0)
24
+ end
25
+
26
+ def size_difference_formatted
27
+ diff = size_difference
28
+ return "no change" if diff == 0
29
+ diff > 0 ? "+#{diff} bytes" : "#{diff} bytes"
30
+ end
31
+
32
+ def changes_description
33
+ changes = []
34
+ changes << "content" if content_changed?
35
+ changes << "CSS" if css_changed?
36
+ changes.empty? ? "No changes" : "Changed: #{changes.join(', ')}"
37
+ end
38
+
39
+ private
40
+
41
+ def set_version_number
42
+ return if version_number.present?
43
+ max_version = page.versions.maximum(:version_number) || 0
44
+ self.version_number = max_version + 1
45
+ end
46
+
47
+ def compute_diff
48
+ return unless content_before.present? && content_after.present?
49
+ self.content_diff = generate_unified_diff(content_before, content_after)
50
+ end
51
+
52
+ def set_content_sizes
53
+ self.content_size_before = content_before&.bytesize || 0
54
+ self.content_size_after = content_after&.bytesize || 0
55
+ end
56
+
57
+ def generate_unified_diff(before, after)
58
+ before_lines = before.to_s.lines
59
+ after_lines = after.to_s.lines
60
+
61
+ # Simple line-by-line diff
62
+ diff_lines = []
63
+ max_lines = [before_lines.size, after_lines.size].max
64
+
65
+ max_lines.times do |i|
66
+ before_line = before_lines[i]
67
+ after_line = after_lines[i]
68
+
69
+ if before_line == after_line
70
+ diff_lines << " #{before_line}"
71
+ else
72
+ diff_lines << "- #{before_line}" if before_line
73
+ diff_lines << "+ #{after_line}" if after_line
74
+ end
75
+ end
76
+
77
+ diff_lines.join
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,73 @@
1
+ module ActiveCanvas
2
+ class Partial < ApplicationRecord
3
+ TYPES = %w[header footer].freeze
4
+
5
+ validates :name, presence: true
6
+ validates :partial_type, presence: true, inclusion: { in: TYPES }, uniqueness: true
7
+
8
+ scope :active, -> { where(active: true) }
9
+
10
+ after_save :compile_tailwind_css, if: :should_compile_css?
11
+
12
+ class << self
13
+ def header
14
+ find_by(partial_type: "header")
15
+ end
16
+
17
+ def footer
18
+ find_by(partial_type: "footer")
19
+ end
20
+
21
+ def active_header
22
+ active.find_by(partial_type: "header")
23
+ end
24
+
25
+ def active_footer
26
+ active.find_by(partial_type: "footer")
27
+ end
28
+
29
+ # Ensure both partials exist
30
+ def ensure_defaults!
31
+ find_or_create_by!(partial_type: "header") do |p|
32
+ p.name = "Header"
33
+ end
34
+ find_or_create_by!(partial_type: "footer") do |p|
35
+ p.name = "Footer"
36
+ end
37
+ end
38
+ end
39
+
40
+ def header?
41
+ partial_type == "header"
42
+ end
43
+
44
+ def footer?
45
+ partial_type == "footer"
46
+ end
47
+
48
+ def rendered_content
49
+ content.to_s.html_safe
50
+ end
51
+
52
+ def full_css
53
+ [compiled_css, content_css].compact.join("\n")
54
+ end
55
+
56
+ private
57
+
58
+ def should_compile_css?
59
+ saved_change_to_content? && content.present? && TailwindCompiler.available?
60
+ end
61
+
62
+ def compile_tailwind_css
63
+ return unless TailwindCompiler.available?
64
+
65
+ begin
66
+ compiled = TailwindCompiler.compile(content.to_s, identifier: "partial ##{id} (#{name})")
67
+ update_column(:compiled_css, compiled)
68
+ rescue TailwindCompiler::CompilationError => e
69
+ Rails.logger.error "[ActiveCanvas] Failed to compile Tailwind CSS for partial #{id}: #{e.message}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,292 @@
1
+ module ActiveCanvas
2
+ class Setting < ApplicationRecord
3
+ validates :key, presence: true, uniqueness: true
4
+
5
+ # Keys that should be encrypted in the database
6
+ ENCRYPTED_KEYS = %w[
7
+ ai_openai_api_key
8
+ ai_anthropic_api_key
9
+ ai_openrouter_api_key
10
+ ].freeze
11
+
12
+ CSS_FRAMEWORKS = {
13
+ "tailwind" => {
14
+ name: "Tailwind CSS",
15
+ url: "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
16
+ type: :script
17
+ },
18
+ "bootstrap5" => {
19
+ name: "Bootstrap 5",
20
+ url: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css",
21
+ type: :stylesheet
22
+ },
23
+ "custom" => {
24
+ name: "Custom CSS (no framework)",
25
+ url: nil,
26
+ type: nil
27
+ }
28
+ }.freeze
29
+
30
+ class << self
31
+ def get(key)
32
+ setting = find_by(key: key)
33
+ return nil unless setting
34
+
35
+ if ENCRYPTED_KEYS.include?(key.to_s) && setting.encrypted_value.present?
36
+ decrypt_value(setting.encrypted_value)
37
+ else
38
+ setting.value
39
+ end
40
+ end
41
+
42
+ def set(key, value)
43
+ setting = find_or_initialize_by(key: key)
44
+
45
+ if ENCRYPTED_KEYS.include?(key.to_s) && value.present?
46
+ setting.encrypted_value = encrypt_value(value)
47
+ setting.value = nil # Clear plain text value
48
+ else
49
+ setting.value = value
50
+ setting.encrypted_value = nil if ENCRYPTED_KEYS.include?(key.to_s)
51
+ end
52
+
53
+ setting.save!
54
+ value
55
+ end
56
+
57
+ # Check if an API key is configured (without revealing the value)
58
+ def api_key_configured?(key)
59
+ setting = find_by(key: key)
60
+ return false unless setting
61
+
62
+ setting.encrypted_value.present? || setting.value.present?
63
+ end
64
+
65
+ # Get a masked version of the API key for display (last 4 chars only)
66
+ def masked_api_key(key)
67
+ value = get(key)
68
+ return nil if value.blank?
69
+
70
+ mask_value(value)
71
+ end
72
+
73
+ private
74
+
75
+ def encrypt_value(value)
76
+ return nil if value.blank?
77
+
78
+ encryptor.encrypt_and_sign(value)
79
+ end
80
+
81
+ def decrypt_value(encrypted_value)
82
+ return nil if encrypted_value.blank?
83
+
84
+ encryptor.decrypt_and_verify(encrypted_value)
85
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage => e
86
+ Rails.logger.error "[ActiveCanvas] Failed to decrypt setting: #{e.message}"
87
+ nil
88
+ end
89
+
90
+ def encryptor
91
+ @encryptor ||= begin
92
+ secret = secret_key_base
93
+ key = ActiveSupport::KeyGenerator.new(secret).generate_key("active_canvas_settings", 32)
94
+ ActiveSupport::MessageEncryptor.new(key)
95
+ end
96
+ end
97
+
98
+ def secret_key_base
99
+ if Rails.application.respond_to?(:secret_key_base)
100
+ Rails.application.secret_key_base
101
+ elsif Rails.application.respond_to?(:credentials) && Rails.application.credentials.secret_key_base
102
+ Rails.application.credentials.secret_key_base
103
+ else
104
+ raise "Rails secret_key_base not available for encryption"
105
+ end
106
+ end
107
+
108
+ def mask_value(value)
109
+ return nil if value.blank?
110
+ return "****" if value.length <= 8
111
+
112
+ "****#{value[-4..]}"
113
+ end
114
+
115
+ public
116
+
117
+ def homepage_page_id
118
+ get("homepage_page_id")&.to_i
119
+ end
120
+
121
+ def homepage_page_id=(page_id)
122
+ set("homepage_page_id", page_id.presence)
123
+ end
124
+
125
+ def homepage
126
+ page_id = homepage_page_id
127
+ return nil unless page_id&.positive?
128
+
129
+ Page.published.find_by(id: page_id)
130
+ end
131
+
132
+ def css_framework
133
+ get("css_framework") || ActiveCanvas.config.css_framework.to_s
134
+ end
135
+
136
+ def css_framework=(framework)
137
+ set("css_framework", framework.presence)
138
+ end
139
+
140
+ def css_framework_config
141
+ CSS_FRAMEWORKS[css_framework] || CSS_FRAMEWORKS["custom"]
142
+ end
143
+
144
+ def css_framework_url
145
+ css_framework_config[:url]
146
+ end
147
+
148
+ def css_framework_type
149
+ css_framework_config[:type]
150
+ end
151
+
152
+ def global_css
153
+ get("global_css") || ""
154
+ end
155
+
156
+ def global_css=(css)
157
+ set("global_css", css)
158
+ end
159
+
160
+ def global_js
161
+ get("global_js") || ""
162
+ end
163
+
164
+ def global_js=(js)
165
+ set("global_js", js)
166
+ end
167
+
168
+ # AI API Keys
169
+ def ai_openai_api_key
170
+ get("ai_openai_api_key")
171
+ end
172
+
173
+ def ai_openai_api_key=(key)
174
+ set("ai_openai_api_key", key.presence)
175
+ end
176
+
177
+ def ai_anthropic_api_key
178
+ get("ai_anthropic_api_key")
179
+ end
180
+
181
+ def ai_anthropic_api_key=(key)
182
+ set("ai_anthropic_api_key", key.presence)
183
+ end
184
+
185
+ def ai_openrouter_api_key
186
+ get("ai_openrouter_api_key")
187
+ end
188
+
189
+ def ai_openrouter_api_key=(key)
190
+ set("ai_openrouter_api_key", key.presence)
191
+ end
192
+
193
+ # AI Default Models
194
+ def ai_default_text_model
195
+ get("ai_default_text_model") || "gpt-4o-mini"
196
+ end
197
+
198
+ def ai_default_text_model=(model)
199
+ set("ai_default_text_model", model.presence)
200
+ end
201
+
202
+ def ai_default_image_model
203
+ get("ai_default_image_model") || "dall-e-3"
204
+ end
205
+
206
+ def ai_default_image_model=(model)
207
+ set("ai_default_image_model", model.presence)
208
+ end
209
+
210
+ def ai_default_vision_model
211
+ get("ai_default_vision_model") || "gpt-4o"
212
+ end
213
+
214
+ def ai_default_vision_model=(model)
215
+ set("ai_default_vision_model", model.presence)
216
+ end
217
+
218
+ # AI Connection Mode
219
+ def ai_connection_mode
220
+ get("ai_connection_mode") || "server"
221
+ end
222
+
223
+ def ai_connection_mode=(value)
224
+ set("ai_connection_mode", value.presence || "server")
225
+ end
226
+
227
+ # AI Feature Toggles
228
+ def ai_text_enabled?
229
+ get("ai_text_enabled") != "false"
230
+ end
231
+
232
+ def ai_text_enabled=(enabled)
233
+ set("ai_text_enabled", enabled.to_s)
234
+ end
235
+
236
+ def ai_image_enabled?
237
+ get("ai_image_enabled") != "false"
238
+ end
239
+
240
+ def ai_image_enabled=(enabled)
241
+ set("ai_image_enabled", enabled.to_s)
242
+ end
243
+
244
+ def ai_screenshot_enabled?
245
+ get("ai_screenshot_enabled") != "false"
246
+ end
247
+
248
+ def ai_screenshot_enabled=(enabled)
249
+ set("ai_screenshot_enabled", enabled.to_s)
250
+ end
251
+
252
+ # Tailwind Configuration
253
+ DEFAULT_TAILWIND_CONFIG = {
254
+ theme: {
255
+ extend: {
256
+ colors: {},
257
+ fontFamily: {}
258
+ }
259
+ }
260
+ }.freeze
261
+
262
+ def tailwind_compiled_mode?
263
+ css_framework == "tailwind" && ActiveCanvas::TailwindCompiler.available?
264
+ end
265
+
266
+ def tailwind_config
267
+ raw = get("tailwind_config")
268
+ return DEFAULT_TAILWIND_CONFIG.deep_dup if raw.blank?
269
+
270
+ JSON.parse(raw).deep_symbolize_keys
271
+ rescue JSON::ParserError
272
+ DEFAULT_TAILWIND_CONFIG.deep_dup
273
+ end
274
+
275
+ def tailwind_config=(config)
276
+ value = case config
277
+ when String
278
+ config
279
+ when Hash
280
+ config.to_json
281
+ else
282
+ config.to_s
283
+ end
284
+ set("tailwind_config", value)
285
+ end
286
+
287
+ def tailwind_config_js
288
+ tailwind_config.to_json
289
+ end
290
+ end
291
+ end
292
+ end