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,1864 @@
1
+ <% content_for :page_title, "Settings" %>
2
+
3
+ <!-- Page Header -->
4
+ <div class="page-header">
5
+ <div class="page-header-left">
6
+ <h2>Settings</h2>
7
+ <p class="page-header-subtitle">Configure your ActiveCanvas installation</p>
8
+ </div>
9
+ </div>
10
+
11
+ <!-- Settings Tabs -->
12
+ <div class="settings-tabs">
13
+ <%= link_to admin_settings_path(tab: "general"), class: "settings-tab #{'active' if @active_tab == 'general'}" do %>
14
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -3px; margin-right: 0.5rem;">
15
+ <circle cx="12" cy="12" r="3"/>
16
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
17
+ </svg>
18
+ General
19
+ <% end %>
20
+ <%= link_to admin_settings_path(tab: "styles"), class: "settings-tab #{'active' if @active_tab == 'styles'}" do %>
21
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -3px; margin-right: 0.5rem;">
22
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
23
+ </svg>
24
+ Global Styles
25
+ <% end %>
26
+ <%= link_to admin_settings_path(tab: "scripts"), class: "settings-tab #{'active' if @active_tab == 'scripts'}" do %>
27
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -3px; margin-right: 0.5rem;">
28
+ <polyline points="16 18 22 12 16 6"/>
29
+ <polyline points="8 6 2 12 8 18"/>
30
+ </svg>
31
+ Global Scripts
32
+ <% end %>
33
+ <%= link_to admin_settings_path(tab: "ai"), class: "settings-tab #{'active' if @active_tab == 'ai'}" do %>
34
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -3px; margin-right: 0.5rem;">
35
+ <path d="M12 8V4H8"/>
36
+ <rect width="16" height="12" x="4" y="8" rx="2"/>
37
+ <path d="M2 14h2"/>
38
+ <path d="M20 14h2"/>
39
+ <path d="M15 13v2"/>
40
+ <path d="M9 13v2"/>
41
+ </svg>
42
+ AI Assistant
43
+ <% end %>
44
+ <%= link_to admin_settings_path(tab: "models"), class: "settings-tab #{'active' if @active_tab == 'models'}" do %>
45
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -3px; margin-right: 0.5rem;">
46
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
47
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
48
+ <line x1="12" y1="22.08" x2="12" y2="12"/>
49
+ </svg>
50
+ AI Models
51
+ <% end %>
52
+ </div>
53
+
54
+ <% if @active_tab == "general" %>
55
+ <div class="card">
56
+ <div class="card-header">
57
+ <span class="card-title">General Settings</span>
58
+ </div>
59
+ <div class="card-body">
60
+ <%= form_with url: admin_settings_path, method: :patch do |form| %>
61
+ <div class="form-group">
62
+ <%= form.label :homepage_page_id, "Homepage" %>
63
+ <%= form.select :homepage_page_id,
64
+ options_for_select(
65
+ [["— No homepage —", ""]] + @pages.map { |p| [p.title, p.id] },
66
+ @homepage_page_id
67
+ ),
68
+ {},
69
+ class: "form-control" %>
70
+ <p class="help-text">Select which published page to display when visiting the root URL.</p>
71
+ </div>
72
+
73
+ <%# CSS Framework is configured via the initializer (config.css_framework) %>
74
+ <%# To change it, update your config/initializers/active_canvas.rb %>
75
+
76
+ <div class="form-actions">
77
+ <%= form.submit "Save Settings", class: "btn btn-primary" %>
78
+ </div>
79
+ <% end %>
80
+ </div>
81
+ </div>
82
+ <% elsif @active_tab == "styles" %>
83
+ <% if @css_framework == "tailwind" %>
84
+ <!-- Tailwind Configuration -->
85
+ <div class="card" style="margin-bottom: 1.5rem;">
86
+ <div class="card-header">
87
+ <span class="card-title">Tailwind CSS Configuration</span>
88
+ <% if @tailwind_compiled_mode %>
89
+ <span class="status-badge status-badge-success">Compiled Mode</span>
90
+ <% elsif @tailwind_available %>
91
+ <span class="status-badge status-badge-warning">CDN Mode</span>
92
+ <% else %>
93
+ <span class="status-badge status-badge-warning">CDN Only</span>
94
+ <% end %>
95
+ </div>
96
+ <div class="card-body">
97
+ <% if @tailwind_available %>
98
+ <div class="tailwind-info-banner tailwind-info-success">
99
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
100
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
101
+ <polyline points="22 4 12 14.01 9 11.01"/>
102
+ </svg>
103
+ <div>
104
+ <strong>Compiled Mode Active</strong>
105
+ <p>Public pages use pre-compiled CSS for optimal performance. The editor uses CDN for live preview.</p>
106
+ </div>
107
+ </div>
108
+ <% else %>
109
+ <div class="tailwind-info-banner">
110
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
111
+ <circle cx="12" cy="12" r="10"/>
112
+ <line x1="12" y1="8" x2="12" y2="12"/>
113
+ <line x1="12" y1="16" x2="12.01" y2="16"/>
114
+ </svg>
115
+ <div>
116
+ <strong>CDN Mode (Install tailwindcss-ruby for better performance)</strong>
117
+ <p>Add <code>gem "tailwindcss-ruby", ">= 4.0"</code> to your Gemfile to enable compiled CSS for public pages.</p>
118
+ </div>
119
+ </div>
120
+ <% end %>
121
+
122
+ <div class="settings-code-header" style="margin-top: 1.5rem;">
123
+ <h4 style="margin: 0; font-size: 0.9375rem;">Custom Configuration (JSON)</h4>
124
+ <p class="help-text" style="margin-top: 0.25rem;">Customize Tailwind's theme, colors, fonts, and more. Applied to both editor preview and compiled output.</p>
125
+ </div>
126
+ <div id="tailwind-config-editor" class="settings-monaco-editor" style="height: 200px;"></div>
127
+ <div class="settings-code-actions">
128
+ <button type="button" id="format-tailwind-btn" class="btn btn-secondary">
129
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
130
+ <line x1="21" y1="10" x2="3" y2="10"/>
131
+ <line x1="21" y1="6" x2="3" y2="6"/>
132
+ <line x1="21" y1="14" x2="3" y2="14"/>
133
+ <line x1="21" y1="18" x2="3" y2="18"/>
134
+ </svg>
135
+ Format
136
+ </button>
137
+ <button type="button" id="save-tailwind-btn" class="btn btn-primary">
138
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
139
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
140
+ <polyline points="17 21 17 13 7 13 7 21"/>
141
+ <polyline points="7 3 7 8 15 8"/>
142
+ </svg>
143
+ Save Config
144
+ </button>
145
+ <% if @tailwind_available %>
146
+ <%= button_to recompile_tailwind_admin_settings_path,
147
+ method: :post,
148
+ class: "btn btn-secondary",
149
+ id: "recompile-tailwind-btn",
150
+ data: { turbo: false, confirm: "Recompile Tailwind CSS for all pages?" } do %>
151
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
152
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
153
+ <path d="M3 3v5h5"/>
154
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
155
+ <path d="M16 16h5v5"/>
156
+ </svg>
157
+ Recompile All Pages
158
+ <% end %>
159
+ <% end %>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ <style>
165
+ .tailwind-info-banner {
166
+ display: flex;
167
+ align-items: flex-start;
168
+ gap: 0.75rem;
169
+ padding: 1rem;
170
+ background: rgba(234, 179, 8, 0.1);
171
+ border: 1px solid rgba(234, 179, 8, 0.3);
172
+ border-radius: 8px;
173
+ color: var(--text-primary);
174
+ }
175
+ .tailwind-info-banner svg {
176
+ color: #eab308;
177
+ flex-shrink: 0;
178
+ margin-top: 2px;
179
+ }
180
+ .tailwind-info-banner strong {
181
+ display: block;
182
+ margin-bottom: 0.25rem;
183
+ }
184
+ .tailwind-info-banner p {
185
+ margin: 0;
186
+ font-size: 0.8125rem;
187
+ color: var(--text-secondary);
188
+ }
189
+ .tailwind-info-banner code {
190
+ background: var(--bg-secondary);
191
+ padding: 0.125rem 0.375rem;
192
+ border-radius: 4px;
193
+ font-size: 0.75rem;
194
+ }
195
+ .tailwind-info-banner.tailwind-info-success {
196
+ background: rgba(34, 197, 94, 0.1);
197
+ border-color: rgba(34, 197, 94, 0.3);
198
+ }
199
+ .tailwind-info-banner.tailwind-info-success svg {
200
+ color: #22c55e;
201
+ }
202
+ .status-badge {
203
+ font-size: 0.75rem;
204
+ font-weight: 500;
205
+ padding: 0.25rem 0.625rem;
206
+ border-radius: 9999px;
207
+ }
208
+ .status-badge-success {
209
+ background: rgba(34, 197, 94, 0.1);
210
+ color: #22c55e;
211
+ }
212
+ .status-badge-warning {
213
+ background: rgba(234, 179, 8, 0.1);
214
+ color: #eab308;
215
+ }
216
+ </style>
217
+ <% end %>
218
+
219
+ <div class="card settings-code-card">
220
+ <div class="settings-code-header">
221
+ <h3>Global CSS</h3>
222
+ <p class="help-text" style="margin-top: 0;">Custom CSS that will be included on all published pages.</p>
223
+ </div>
224
+ <div id="global-css-editor" class="settings-monaco-editor"></div>
225
+ <div class="settings-code-actions">
226
+ <button type="button" id="format-css-btn" class="btn btn-secondary">
227
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
228
+ <line x1="21" y1="10" x2="3" y2="10"/>
229
+ <line x1="21" y1="6" x2="3" y2="6"/>
230
+ <line x1="21" y1="14" x2="3" y2="14"/>
231
+ <line x1="21" y1="18" x2="3" y2="18"/>
232
+ </svg>
233
+ Format
234
+ </button>
235
+ <button type="button" id="save-css-btn" class="btn btn-primary">
236
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
237
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
238
+ <polyline points="17 21 17 13 7 13 7 21"/>
239
+ <polyline points="7 3 7 8 15 8"/>
240
+ </svg>
241
+ Save Global CSS
242
+ </button>
243
+ </div>
244
+ </div>
245
+
246
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
247
+ <script>
248
+ require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } });
249
+ window.MonacoEnvironment = {
250
+ getWorkerUrl: function() {
251
+ return URL.createObjectURL(new Blob([`
252
+ self.MonacoEnvironment = {
253
+ baseUrl: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min'
254
+ };
255
+ importScripts('https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/base/worker/workerMain.js');
256
+ `], { type: 'text/javascript' }));
257
+ }
258
+ };
259
+
260
+ require(['vs/editor/editor.main'], function() {
261
+ const existingCss = <%= raw @global_css.to_json %>;
262
+ const existingTailwindConfig = <%= raw @tailwind_config %>;
263
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
264
+ const isTailwind = '<%= @css_framework %>' === 'tailwind';
265
+
266
+ monaco.editor.defineTheme('activeCanvasDark', {
267
+ base: 'vs-dark',
268
+ inherit: true,
269
+ rules: [],
270
+ colors: {
271
+ 'editor.background': '#0f172a',
272
+ 'editor.foreground': '#e2e8f0',
273
+ 'editorLineNumber.foreground': '#64748b',
274
+ 'editorCursor.foreground': '#6366f1',
275
+ 'editor.selectionBackground': '#334155',
276
+ 'editor.lineHighlightBackground': '#1e293b'
277
+ }
278
+ });
279
+
280
+ // Tailwind Config Editor (if Tailwind is selected)
281
+ if (isTailwind && document.getElementById('tailwind-config-editor')) {
282
+ const tailwindEditor = monaco.editor.create(document.getElementById('tailwind-config-editor'), {
283
+ value: JSON.stringify(existingTailwindConfig, null, 2),
284
+ language: 'json',
285
+ theme: 'activeCanvasDark',
286
+ fontSize: 13,
287
+ lineNumbers: 'on',
288
+ minimap: { enabled: false },
289
+ scrollBeyondLastLine: false,
290
+ automaticLayout: true,
291
+ tabSize: 2,
292
+ wordWrap: 'on',
293
+ folding: true,
294
+ padding: { top: 16 }
295
+ });
296
+
297
+ document.getElementById('format-tailwind-btn').addEventListener('click', () => {
298
+ try {
299
+ const parsed = JSON.parse(tailwindEditor.getValue());
300
+ tailwindEditor.setValue(JSON.stringify(parsed, null, 2));
301
+ } catch (e) {
302
+ alert('Invalid JSON: ' + e.message);
303
+ }
304
+ });
305
+
306
+ document.getElementById('save-tailwind-btn').addEventListener('click', () => {
307
+ const btn = document.getElementById('save-tailwind-btn');
308
+ let configValue;
309
+ try {
310
+ configValue = JSON.parse(tailwindEditor.getValue());
311
+ } catch (e) {
312
+ alert('Invalid JSON: ' + e.message);
313
+ return;
314
+ }
315
+
316
+ btn.disabled = true;
317
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: spin 1s linear infinite;"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg> Saving...';
318
+
319
+ fetch('<%= update_tailwind_config_admin_settings_path %>', {
320
+ method: 'PATCH',
321
+ headers: {
322
+ 'Content-Type': 'application/json',
323
+ 'X-CSRF-Token': csrfToken,
324
+ 'Accept': 'application/json'
325
+ },
326
+ body: JSON.stringify({ tailwind_config: JSON.stringify(configValue) })
327
+ })
328
+ .then(response => response.json())
329
+ .then(result => {
330
+ btn.innerHTML = result.success
331
+ ? '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Saved!'
332
+ : '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> Error';
333
+ setTimeout(() => {
334
+ btn.disabled = false;
335
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save Config';
336
+ }, 2000);
337
+ })
338
+ .catch(error => {
339
+ btn.disabled = false;
340
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save Config';
341
+ console.error('Save error:', error);
342
+ });
343
+ });
344
+
345
+ tailwindEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
346
+ document.getElementById('save-tailwind-btn').click();
347
+ });
348
+ }
349
+
350
+ // Global CSS Editor
351
+ const cssEditor = monaco.editor.create(document.getElementById('global-css-editor'), {
352
+ value: existingCss || '',
353
+ language: 'css',
354
+ theme: 'activeCanvasDark',
355
+ fontSize: 13,
356
+ lineNumbers: 'on',
357
+ minimap: { enabled: false },
358
+ scrollBeyondLastLine: false,
359
+ automaticLayout: true,
360
+ tabSize: 2,
361
+ wordWrap: 'on',
362
+ folding: true,
363
+ padding: { top: 16 }
364
+ });
365
+
366
+ document.getElementById('format-css-btn').addEventListener('click', () => {
367
+ cssEditor.getAction('editor.action.formatDocument').run();
368
+ });
369
+
370
+ document.getElementById('save-css-btn').addEventListener('click', () => {
371
+ const btn = document.getElementById('save-css-btn');
372
+ btn.disabled = true;
373
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: spin 1s linear infinite;"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg> Saving...';
374
+
375
+ fetch('<%= update_global_css_admin_settings_path %>', {
376
+ method: 'PATCH',
377
+ headers: {
378
+ 'Content-Type': 'application/json',
379
+ 'X-CSRF-Token': csrfToken,
380
+ 'Accept': 'application/json'
381
+ },
382
+ body: JSON.stringify({ global_css: cssEditor.getValue() })
383
+ })
384
+ .then(response => response.json())
385
+ .then(result => {
386
+ btn.innerHTML = result.success
387
+ ? '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Saved!'
388
+ : '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> Error';
389
+ setTimeout(() => {
390
+ btn.disabled = false;
391
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save Global CSS';
392
+ }, 2000);
393
+ })
394
+ .catch(error => {
395
+ btn.disabled = false;
396
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save Global CSS';
397
+ console.error('Save error:', error);
398
+ });
399
+ });
400
+
401
+ cssEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
402
+ document.getElementById('save-css-btn').click();
403
+ });
404
+ });
405
+ </script>
406
+ <style>
407
+ @keyframes spin {
408
+ from { transform: rotate(0deg); }
409
+ to { transform: rotate(360deg); }
410
+ }
411
+ </style>
412
+ <% elsif @active_tab == "scripts" %>
413
+ <div class="card settings-code-card">
414
+ <div class="settings-code-header">
415
+ <h3>Global JavaScript</h3>
416
+ <p class="help-text" style="margin-top: 0;">Custom JavaScript that will be included on all published pages.</p>
417
+ </div>
418
+ <div id="global-js-editor" class="settings-monaco-editor"></div>
419
+ <div class="settings-code-actions">
420
+ <button type="button" id="format-js-btn" class="btn btn-secondary">
421
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
422
+ <line x1="21" y1="10" x2="3" y2="10"/>
423
+ <line x1="21" y1="6" x2="3" y2="6"/>
424
+ <line x1="21" y1="14" x2="3" y2="14"/>
425
+ <line x1="21" y1="18" x2="3" y2="18"/>
426
+ </svg>
427
+ Format
428
+ </button>
429
+ <button type="button" id="save-js-btn" class="btn btn-primary">
430
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
431
+ <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
432
+ <polyline points="17 21 17 13 7 13 7 21"/>
433
+ <polyline points="7 3 7 8 15 8"/>
434
+ </svg>
435
+ Save Global JavaScript
436
+ </button>
437
+ </div>
438
+ </div>
439
+
440
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
441
+ <script>
442
+ require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } });
443
+ window.MonacoEnvironment = {
444
+ getWorkerUrl: function() {
445
+ return URL.createObjectURL(new Blob([`
446
+ self.MonacoEnvironment = {
447
+ baseUrl: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min'
448
+ };
449
+ importScripts('https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/base/worker/workerMain.js');
450
+ `], { type: 'text/javascript' }));
451
+ }
452
+ };
453
+
454
+ require(['vs/editor/editor.main'], function() {
455
+ const existingJs = <%= raw @global_js.to_json %>;
456
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
457
+
458
+ monaco.editor.defineTheme('activeCanvasDark', {
459
+ base: 'vs-dark',
460
+ inherit: true,
461
+ rules: [],
462
+ colors: {
463
+ 'editor.background': '#0f172a',
464
+ 'editor.foreground': '#e2e8f0',
465
+ 'editorLineNumber.foreground': '#64748b',
466
+ 'editorCursor.foreground': '#6366f1',
467
+ 'editor.selectionBackground': '#334155',
468
+ 'editor.lineHighlightBackground': '#1e293b'
469
+ }
470
+ });
471
+
472
+ const jsEditor = monaco.editor.create(document.getElementById('global-js-editor'), {
473
+ value: existingJs || '',
474
+ language: 'javascript',
475
+ theme: 'activeCanvasDark',
476
+ fontSize: 13,
477
+ lineNumbers: 'on',
478
+ minimap: { enabled: false },
479
+ scrollBeyondLastLine: false,
480
+ automaticLayout: true,
481
+ tabSize: 2,
482
+ wordWrap: 'on',
483
+ folding: true,
484
+ padding: { top: 16 }
485
+ });
486
+
487
+ document.getElementById('format-js-btn').addEventListener('click', () => {
488
+ jsEditor.getAction('editor.action.formatDocument').run();
489
+ });
490
+
491
+ document.getElementById('save-js-btn').addEventListener('click', () => {
492
+ const btn = document.getElementById('save-js-btn');
493
+ btn.disabled = true;
494
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation: spin 1s linear infinite;"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg> Saving...';
495
+
496
+ fetch('<%= update_global_js_admin_settings_path %>', {
497
+ method: 'PATCH',
498
+ headers: {
499
+ 'Content-Type': 'application/json',
500
+ 'X-CSRF-Token': csrfToken,
501
+ 'Accept': 'application/json'
502
+ },
503
+ body: JSON.stringify({ global_js: jsEditor.getValue() })
504
+ })
505
+ .then(response => response.json())
506
+ .then(result => {
507
+ btn.innerHTML = result.success
508
+ ? '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Saved!'
509
+ : '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> Error';
510
+ setTimeout(() => {
511
+ btn.disabled = false;
512
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save Global JavaScript';
513
+ }, 2000);
514
+ })
515
+ .catch(error => {
516
+ btn.disabled = false;
517
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save Global JavaScript';
518
+ console.error('Save error:', error);
519
+ });
520
+ });
521
+
522
+ jsEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
523
+ document.getElementById('save-js-btn').click();
524
+ });
525
+ });
526
+ </script>
527
+ <style>
528
+ @keyframes spin {
529
+ from { transform: rotate(0deg); }
530
+ to { transform: rotate(360deg); }
531
+ }
532
+ </style>
533
+ <% elsif @active_tab == "ai" %>
534
+ <div class="card">
535
+ <div class="card-header">
536
+ <span class="card-title">AI Assistant Configuration</span>
537
+ <% if ActiveCanvas::AiConfiguration.configured? %>
538
+ <span class="status-badge status-badge-success">Configured</span>
539
+ <% else %>
540
+ <span class="status-badge status-badge-warning">Not Configured</span>
541
+ <% end %>
542
+ </div>
543
+ <div class="card-body">
544
+ <%= form_with url: update_ai_admin_settings_path, method: :patch, local: true do |form| %>
545
+ <div class="settings-section">
546
+ <h4>API Keys</h4>
547
+ <p class="help-text">Add at least one API key to enable AI features. Keys are stored securely in the database.</p>
548
+
549
+ <div class="form-group">
550
+ <%= form.label :ai_openai_api_key, "OpenAI API Key" %>
551
+ <div class="api-key-input">
552
+ <%= form.password_field :ai_openai_api_key,
553
+ value: "",
554
+ placeholder: @ai_openai_configured ? "#{@ai_openai_key} (leave blank to keep)" : "sk-...",
555
+ class: "form-control",
556
+ autocomplete: "off" %>
557
+ <% if @ai_openai_configured %>
558
+ <span class="api-key-status configured">Configured</span>
559
+ <% end %>
560
+ </div>
561
+ <p class="help-text">Get your key at <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">platform.openai.com</a>. Keys are encrypted in the database.</p>
562
+ </div>
563
+
564
+ <div class="form-group">
565
+ <%= form.label :ai_anthropic_api_key, "Anthropic API Key" %>
566
+ <div class="api-key-input">
567
+ <%= form.password_field :ai_anthropic_api_key,
568
+ value: "",
569
+ placeholder: @ai_anthropic_configured ? "#{@ai_anthropic_key} (leave blank to keep)" : "sk-ant-...",
570
+ class: "form-control",
571
+ autocomplete: "off" %>
572
+ <% if @ai_anthropic_configured %>
573
+ <span class="api-key-status configured">Configured</span>
574
+ <% end %>
575
+ </div>
576
+ <p class="help-text">Get your key at <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">console.anthropic.com</a>. Keys are encrypted in the database.</p>
577
+ </div>
578
+
579
+ <div class="form-group">
580
+ <%= form.label :ai_openrouter_api_key, "OpenRouter API Key" %>
581
+ <div class="api-key-input">
582
+ <%= form.password_field :ai_openrouter_api_key,
583
+ value: "",
584
+ placeholder: @ai_openrouter_configured ? "#{@ai_openrouter_key} (leave blank to keep)" : "sk-or-...",
585
+ class: "form-control",
586
+ autocomplete: "off" %>
587
+ <% if @ai_openrouter_configured %>
588
+ <span class="api-key-status configured">Configured</span>
589
+ <% end %>
590
+ </div>
591
+ <p class="help-text">Get your key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai</a>. Keys are encrypted in the database.</p>
592
+ </div>
593
+ </div>
594
+
595
+ <div class="settings-section">
596
+ <h4>Connection Mode</h4>
597
+ <p class="help-text">Choose how AI requests are routed between the editor and AI providers.</p>
598
+
599
+ <div class="form-group">
600
+ <div class="radio-group">
601
+ <label class="radio-label">
602
+ <%= form.radio_button :ai_connection_mode, "server", checked: @ai_connection_mode == "server" %>
603
+ <span class="radio-text">
604
+ <strong>Server (proxied)</strong>
605
+ <span class="radio-description">AI requests are proxied through your Rails backend. Recommended for production. If you experience timeouts, increase your server's <code>worker_timeout</code> to at least 180 seconds (see README).</span>
606
+ </span>
607
+ </label>
608
+
609
+ <label class="radio-label">
610
+ <%= form.radio_button :ai_connection_mode, "direct", checked: @ai_connection_mode == "direct" %>
611
+ <span class="radio-text">
612
+ <strong>Direct (browser)</strong>
613
+ <span class="radio-description">AI requests go directly from the browser to provider APIs. API keys will be exposed to anyone with admin access to the editor. Only use in trusted environments.</span>
614
+ </span>
615
+ </label>
616
+ </div>
617
+
618
+ <% if @ai_connection_mode == "direct" %>
619
+ <div class="connection-mode-warning" style="margin-top: 0.75rem;">
620
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
621
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
622
+ <line x1="12" y1="9" x2="12" y2="13"/>
623
+ <line x1="12" y1="17" x2="12.01" y2="17"/>
624
+ </svg>
625
+ <span>Anthropic direct mode requires the <code>anthropic-dangerous-direct-browser-access</code> header. Some browsers or network policies may block these requests.</span>
626
+ </div>
627
+ <% end %>
628
+ </div>
629
+ </div>
630
+
631
+ <div class="settings-section">
632
+ <h4>Default Models</h4>
633
+ <p class="help-text">Select default models for each feature. Users can override in the editor.</p>
634
+
635
+ <div class="form-group">
636
+ <%= form.label :ai_default_text_model, "Default Text Model" %>
637
+ <%= form.select :ai_default_text_model,
638
+ options_for_select(
639
+ @ai_text_models.map { |m| ["#{m[:name]} (#{m[:provider]})", m[:id]] },
640
+ @ai_default_text_model
641
+ ),
642
+ {},
643
+ class: "form-control" %>
644
+ <p class="help-text">Used for text and HTML generation in the editor.</p>
645
+ </div>
646
+
647
+ <div class="form-row">
648
+ <div class="form-group form-group-half">
649
+ <%= form.label :ai_default_image_model, "Default Image Model" %>
650
+ <%= form.select :ai_default_image_model,
651
+ options_for_select(
652
+ @ai_image_models.map { |m| ["#{m[:name]} (#{m[:provider]})", m[:id]] },
653
+ @ai_default_image_model
654
+ ),
655
+ {},
656
+ class: "form-control" %>
657
+ <p class="help-text">Used for image generation.</p>
658
+ </div>
659
+
660
+ <div class="form-group form-group-half">
661
+ <%= form.label :ai_default_vision_model, "Default Vision Model" %>
662
+ <%= form.select :ai_default_vision_model,
663
+ options_for_select(
664
+ @ai_vision_models.map { |m| ["#{m[:name]} (#{m[:provider]})", m[:id]] },
665
+ @ai_default_vision_model
666
+ ),
667
+ {},
668
+ class: "form-control" %>
669
+ <p class="help-text">Used for screenshot-to-code conversion.</p>
670
+ </div>
671
+ </div>
672
+ </div>
673
+
674
+ <div class="settings-section">
675
+ <h4>Feature Toggles</h4>
676
+ <p class="help-text">Enable or disable specific AI features in the editor.</p>
677
+
678
+ <div class="form-group checkbox-group">
679
+ <label class="checkbox-label">
680
+ <%= form.check_box :ai_text_enabled, { checked: @ai_text_enabled }, "1", "0" %>
681
+ <span class="checkbox-text">
682
+ <strong>Text/HTML Generation</strong>
683
+ <span class="checkbox-description">Generate page sections, components, and content using AI</span>
684
+ </span>
685
+ </label>
686
+ </div>
687
+
688
+ <div class="form-group checkbox-group">
689
+ <label class="checkbox-label">
690
+ <%= form.check_box :ai_image_enabled, { checked: @ai_image_enabled }, "1", "0" %>
691
+ <span class="checkbox-text">
692
+ <strong>Image Generation</strong>
693
+ <span class="checkbox-description">Generate images using DALL-E or other models</span>
694
+ </span>
695
+ </label>
696
+ </div>
697
+
698
+ <div class="form-group checkbox-group">
699
+ <label class="checkbox-label">
700
+ <%= form.check_box :ai_screenshot_enabled, { checked: @ai_screenshot_enabled }, "1", "0" %>
701
+ <span class="checkbox-text">
702
+ <strong>Screenshot to Code</strong>
703
+ <span class="checkbox-description">Convert uploaded screenshots into HTML using vision AI</span>
704
+ </span>
705
+ </label>
706
+ </div>
707
+ </div>
708
+
709
+ <div class="form-actions">
710
+ <%= form.submit "Save AI Settings", class: "btn btn-primary" %>
711
+ </div>
712
+ <% end %>
713
+ </div>
714
+ </div>
715
+
716
+ <!-- Model Sync Card -->
717
+ <div class="card" style="margin-top: 1.5rem;">
718
+ <div class="card-header">
719
+ <span class="card-title">AI Models Database</span>
720
+ <% if @ai_models_synced %>
721
+ <span class="status-badge status-badge-success"><%= @ai_models_count %> models</span>
722
+ <% else %>
723
+ <span class="status-badge status-badge-warning">Not synced</span>
724
+ <% end %>
725
+ </div>
726
+ <div class="card-body">
727
+ <div class="model-sync-section">
728
+ <div class="model-sync-info">
729
+ <p class="help-text" style="margin: 0;">
730
+ Sync available AI models from configured providers. This fetches the latest models from OpenAI, Anthropic, and OpenRouter based on your API keys.
731
+ </p>
732
+ <% if @ai_models_synced && @ai_models_last_synced %>
733
+ <p class="model-sync-status">
734
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
735
+ <circle cx="12" cy="12" r="10"/>
736
+ <polyline points="12 6 12 12 16 14"/>
737
+ </svg>
738
+ Last synced: <%= time_ago_in_words(@ai_models_last_synced) %> ago
739
+ </p>
740
+ <% end %>
741
+ </div>
742
+ <div class="model-sync-actions">
743
+ <% if ActiveCanvas::AiConfiguration.configured? %>
744
+ <%= button_to sync_ai_models_admin_settings_path,
745
+ method: :post,
746
+ class: "btn btn-secondary",
747
+ id: "sync-models-btn",
748
+ data: { disable_with: "Syncing..." } do %>
749
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="sync-icon">
750
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
751
+ <path d="M3 3v5h5"/>
752
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
753
+ <path d="M16 16h5v5"/>
754
+ </svg>
755
+ <span>Sync Models</span>
756
+ <% end %>
757
+ <% else %>
758
+ <button type="button" class="btn btn-secondary" disabled title="Configure at least one API key first">
759
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
760
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
761
+ <path d="M3 3v5h5"/>
762
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
763
+ <path d="M16 16h5v5"/>
764
+ </svg>
765
+ <span>Sync Models</span>
766
+ </button>
767
+ <p class="help-text" style="margin: 0.5rem 0 0 0; font-size: 0.75rem;">Add an API key above to enable sync</p>
768
+ <% end %>
769
+ </div>
770
+ </div>
771
+
772
+ <% if @ai_models_synced %>
773
+ <div class="model-stats">
774
+ <div class="model-stat">
775
+ <span class="model-stat-value"><%= @ai_text_models.count %></span>
776
+ <span class="model-stat-label">Text Models</span>
777
+ </div>
778
+ <div class="model-stat">
779
+ <span class="model-stat-value"><%= @ai_image_models.count %></span>
780
+ <span class="model-stat-label">Image Models</span>
781
+ </div>
782
+ <div class="model-stat">
783
+ <span class="model-stat-value"><%= @ai_models_count %></span>
784
+ <span class="model-stat-label">Total Models</span>
785
+ </div>
786
+ </div>
787
+ <% end %>
788
+ </div>
789
+ </div>
790
+
791
+ <style>
792
+ .settings-section {
793
+ margin-bottom: 2rem;
794
+ padding-bottom: 2rem;
795
+ border-bottom: 1px solid var(--border-color);
796
+ }
797
+ .settings-section:last-of-type {
798
+ border-bottom: none;
799
+ margin-bottom: 0;
800
+ padding-bottom: 0;
801
+ }
802
+ .settings-section h4 {
803
+ margin: 0 0 0.5rem 0;
804
+ font-size: 1rem;
805
+ font-weight: 600;
806
+ color: var(--text-primary);
807
+ }
808
+ .settings-section > .help-text {
809
+ margin-bottom: 1.5rem;
810
+ }
811
+ .api-key-input {
812
+ display: flex;
813
+ align-items: center;
814
+ gap: 0.75rem;
815
+ }
816
+ .api-key-input .form-control {
817
+ flex: 1;
818
+ font-family: monospace;
819
+ }
820
+ .api-key-status {
821
+ font-size: 0.75rem;
822
+ font-weight: 500;
823
+ padding: 0.25rem 0.5rem;
824
+ border-radius: 4px;
825
+ white-space: nowrap;
826
+ }
827
+ .api-key-status.configured {
828
+ background: rgba(34, 197, 94, 0.1);
829
+ color: #22c55e;
830
+ }
831
+ .form-row {
832
+ display: flex;
833
+ gap: 1.5rem;
834
+ }
835
+ .form-group-half {
836
+ flex: 1;
837
+ }
838
+ .checkbox-group {
839
+ margin-bottom: 1rem;
840
+ }
841
+ .checkbox-label {
842
+ display: flex;
843
+ align-items: flex-start;
844
+ gap: 0.75rem;
845
+ cursor: pointer;
846
+ }
847
+ .checkbox-label input[type="checkbox"] {
848
+ width: 18px;
849
+ height: 18px;
850
+ margin-top: 2px;
851
+ accent-color: var(--primary-color);
852
+ }
853
+ .checkbox-text {
854
+ display: flex;
855
+ flex-direction: column;
856
+ gap: 0.125rem;
857
+ }
858
+ .checkbox-text strong {
859
+ color: var(--text-primary);
860
+ }
861
+ .checkbox-description {
862
+ font-size: 0.8125rem;
863
+ color: var(--text-secondary);
864
+ }
865
+ .status-badge {
866
+ font-size: 0.75rem;
867
+ font-weight: 500;
868
+ padding: 0.25rem 0.625rem;
869
+ border-radius: 9999px;
870
+ }
871
+ .status-badge-success {
872
+ background: rgba(34, 197, 94, 0.1);
873
+ color: #22c55e;
874
+ }
875
+ .status-badge-warning {
876
+ background: rgba(234, 179, 8, 0.1);
877
+ color: #eab308;
878
+ }
879
+ /* Radio Group */
880
+ .radio-group {
881
+ display: flex;
882
+ flex-direction: column;
883
+ gap: 0.75rem;
884
+ }
885
+ .radio-label {
886
+ display: flex;
887
+ align-items: flex-start;
888
+ gap: 0.75rem;
889
+ cursor: pointer;
890
+ }
891
+ .radio-label input[type="radio"] {
892
+ width: 18px;
893
+ height: 18px;
894
+ margin-top: 2px;
895
+ accent-color: var(--primary-color);
896
+ }
897
+ .radio-text {
898
+ display: flex;
899
+ flex-direction: column;
900
+ gap: 0.125rem;
901
+ }
902
+ .radio-text strong {
903
+ color: var(--text-primary);
904
+ }
905
+ .radio-description {
906
+ font-size: 0.8125rem;
907
+ color: var(--text-secondary);
908
+ }
909
+ .radio-description code {
910
+ background: var(--bg-secondary);
911
+ padding: 0.125rem 0.375rem;
912
+ border-radius: 4px;
913
+ font-size: 0.75rem;
914
+ }
915
+ .connection-mode-warning {
916
+ display: flex;
917
+ align-items: flex-start;
918
+ gap: 0.5rem;
919
+ padding: 0.75rem 1rem;
920
+ background: rgba(234, 179, 8, 0.1);
921
+ border: 1px solid rgba(234, 179, 8, 0.3);
922
+ border-radius: 8px;
923
+ font-size: 0.8125rem;
924
+ color: var(--text-secondary);
925
+ }
926
+ .connection-mode-warning svg {
927
+ color: #eab308;
928
+ flex-shrink: 0;
929
+ margin-top: 1px;
930
+ }
931
+ .connection-mode-warning code {
932
+ background: var(--bg-secondary);
933
+ padding: 0.125rem 0.375rem;
934
+ border-radius: 4px;
935
+ font-size: 0.75rem;
936
+ }
937
+
938
+ @media (max-width: 640px) {
939
+ .form-row {
940
+ flex-direction: column;
941
+ gap: 0;
942
+ }
943
+ }
944
+
945
+ /* Model Sync Styles */
946
+ .model-sync-section {
947
+ display: flex;
948
+ justify-content: space-between;
949
+ align-items: flex-start;
950
+ gap: 1.5rem;
951
+ }
952
+ .model-sync-info {
953
+ flex: 1;
954
+ }
955
+ .model-sync-status {
956
+ display: flex;
957
+ align-items: center;
958
+ gap: 0.5rem;
959
+ margin-top: 0.75rem;
960
+ font-size: 0.8125rem;
961
+ color: var(--text-secondary);
962
+ }
963
+ .model-sync-status svg {
964
+ color: var(--text-tertiary);
965
+ }
966
+ .model-sync-actions {
967
+ flex-shrink: 0;
968
+ }
969
+ .model-sync-actions .btn {
970
+ display: inline-flex;
971
+ align-items: center;
972
+ gap: 0.5rem;
973
+ }
974
+ .model-sync-actions .btn .sync-icon {
975
+ transition: transform 0.3s ease;
976
+ }
977
+ .model-sync-actions .btn:hover .sync-icon {
978
+ transform: rotate(180deg);
979
+ }
980
+ .model-sync-actions .btn[disabled] {
981
+ opacity: 0.5;
982
+ cursor: not-allowed;
983
+ }
984
+ .model-stats {
985
+ display: flex;
986
+ gap: 1.5rem;
987
+ margin-top: 1.5rem;
988
+ padding-top: 1.5rem;
989
+ border-top: 1px solid var(--border-color);
990
+ }
991
+ .model-stat {
992
+ display: flex;
993
+ flex-direction: column;
994
+ align-items: center;
995
+ padding: 1rem 1.5rem;
996
+ background: var(--bg-secondary);
997
+ border-radius: 8px;
998
+ min-width: 100px;
999
+ }
1000
+ .model-stat-value {
1001
+ font-size: 1.5rem;
1002
+ font-weight: 600;
1003
+ color: var(--text-primary);
1004
+ }
1005
+ .model-stat-label {
1006
+ font-size: 0.75rem;
1007
+ color: var(--text-secondary);
1008
+ margin-top: 0.25rem;
1009
+ }
1010
+ @media (max-width: 640px) {
1011
+ .model-sync-section {
1012
+ flex-direction: column;
1013
+ }
1014
+ .model-stats {
1015
+ flex-wrap: wrap;
1016
+ }
1017
+ .model-stat {
1018
+ flex: 1;
1019
+ min-width: 80px;
1020
+ }
1021
+ }
1022
+ </style>
1023
+ <% elsif @active_tab == "models" %>
1024
+ <!-- Add Custom Model -->
1025
+ <div class="card add-model-card" style="margin-bottom: 1rem;">
1026
+ <div class="card-body" style="padding: 1rem 1.5rem;">
1027
+ <button type="button" class="add-model-toggle" onclick="toggleAddModelForm()">
1028
+ <svg class="add-model-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1029
+ <line x1="12" y1="5" x2="12" y2="19"/>
1030
+ <line x1="5" y1="12" x2="19" y2="12"/>
1031
+ </svg>
1032
+ Add Custom Model
1033
+ </button>
1034
+ <div class="add-model-form" id="add-model-form" style="display: none;">
1035
+ <%= form_with url: create_ai_model_admin_settings_path, method: :post, local: true do |f| %>
1036
+ <div class="add-model-grid">
1037
+ <div class="form-group">
1038
+ <%= f.label :model_id, "Model ID" %>
1039
+ <%= f.text_field :model_id, class: "form-control", required: true, placeholder: "e.g. my-custom/model-v1" %>
1040
+ </div>
1041
+ <div class="form-group">
1042
+ <%= f.label :name, "Display Name" %>
1043
+ <%= f.text_field :name, class: "form-control", placeholder: "e.g. My Custom Model" %>
1044
+ </div>
1045
+ </div>
1046
+ <div class="add-model-grid">
1047
+ <div class="form-group">
1048
+ <%= f.label :provider, "Provider" %>
1049
+ <%= f.text_field :provider, class: "form-control", required: true, placeholder: "e.g. openai, anthropic, custom" %>
1050
+ </div>
1051
+ <div class="form-group">
1052
+ <%= f.label :model_type, "Model Type" %>
1053
+ <%= f.select :model_type, options_for_select([["Chat", "chat"], ["Image", "image"], ["Embedding", "embedding"], ["Audio", "audio"]], "chat"), {}, class: "form-control" %>
1054
+ </div>
1055
+ </div>
1056
+ <div class="add-model-grid">
1057
+ <div class="form-group">
1058
+ <%= f.label :context_window, "Context Window" %>
1059
+ <%= f.number_field :context_window, class: "form-control", placeholder: "e.g. 128000" %>
1060
+ </div>
1061
+ <div class="form-group">
1062
+ <%= f.label :max_tokens, "Max Output Tokens" %>
1063
+ <%= f.number_field :max_tokens, class: "form-control", placeholder: "e.g. 4096" %>
1064
+ </div>
1065
+ </div>
1066
+
1067
+ <div class="form-group">
1068
+ <label>Input Modalities</label>
1069
+ <div class="modality-checkboxes">
1070
+ <label class="modality-check"><input type="checkbox" name="input_modalities[]" value="text" checked> text</label>
1071
+ <label class="modality-check"><input type="checkbox" name="input_modalities[]" value="image"> image</label>
1072
+ </div>
1073
+ </div>
1074
+
1075
+ <div class="form-group">
1076
+ <label>Output Modalities</label>
1077
+ <div class="modality-checkboxes">
1078
+ <label class="modality-check"><input type="checkbox" name="output_modalities[]" value="text" checked> text</label>
1079
+ <label class="modality-check"><input type="checkbox" name="output_modalities[]" value="image"> image</label>
1080
+ <label class="modality-check"><input type="checkbox" name="output_modalities[]" value="embedding"> embedding</label>
1081
+ <label class="modality-check"><input type="checkbox" name="output_modalities[]" value="audio"> audio</label>
1082
+ </div>
1083
+ </div>
1084
+
1085
+ <div class="modality-checkboxes" style="margin-bottom: 1rem;">
1086
+ <label class="modality-check"><input type="checkbox" name="supports_functions" value="1"> Supports function calling</label>
1087
+ <label class="modality-check"><input type="checkbox" name="active" value="1" checked> Active</label>
1088
+ </div>
1089
+
1090
+ <div class="form-actions" style="margin: 0;">
1091
+ <%= f.submit "Add Model", class: "btn btn-primary btn-sm" %>
1092
+ </div>
1093
+ <% end %>
1094
+ </div>
1095
+ </div>
1096
+ </div>
1097
+
1098
+ <script>
1099
+ function toggleAddModelForm() {
1100
+ const form = document.getElementById('add-model-form');
1101
+ const icon = document.querySelector('.add-model-icon');
1102
+ const isHidden = form.style.display === 'none';
1103
+ form.style.display = isHidden ? 'block' : 'none';
1104
+ icon.style.transform = isHidden ? 'rotate(45deg)' : '';
1105
+ }
1106
+ </script>
1107
+
1108
+ <% if @ai_models_synced && @all_models_by_provider.present? %>
1109
+ <!-- Search and Bulk Actions -->
1110
+ <div class="card" style="margin-bottom: 1rem;">
1111
+ <div class="card-body" style="padding: 1rem 1.5rem;">
1112
+ <div class="models-toolbar">
1113
+ <div class="models-search">
1114
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1115
+ <circle cx="11" cy="11" r="8"/>
1116
+ <path d="m21 21-4.3-4.3"/>
1117
+ </svg>
1118
+ <input type="text" id="models-search-input" placeholder="Search models..." oninput="filterModels(this.value)">
1119
+ <span class="models-search-count" id="models-search-count"></span>
1120
+ </div>
1121
+ </div>
1122
+ <div class="bulk-actions">
1123
+ <div class="bulk-actions-left">
1124
+ <span class="bulk-actions-label">Bulk Actions:</span>
1125
+ <div class="bulk-actions-buttons">
1126
+ <%= button_to bulk_toggle_ai_models_admin_settings_path(action_type: "activate", scope: "all"),
1127
+ method: :patch,
1128
+ class: "btn btn-sm btn-secondary",
1129
+ data: { turbo: false, confirm: "Activate all models?" } do %>
1130
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1131
+ <polyline points="20 6 9 17 4 12"/>
1132
+ </svg>
1133
+ Activate All
1134
+ <% end %>
1135
+ <%= button_to bulk_toggle_ai_models_admin_settings_path(action_type: "deactivate", scope: "all"),
1136
+ method: :patch,
1137
+ class: "btn btn-sm btn-secondary",
1138
+ data: { turbo: false, confirm: "Deactivate all models?" } do %>
1139
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1140
+ <line x1="18" y1="6" x2="6" y2="18"/>
1141
+ <line x1="6" y1="6" x2="18" y2="18"/>
1142
+ </svg>
1143
+ Deactivate All
1144
+ <% end %>
1145
+ </div>
1146
+ </div>
1147
+ <div class="bulk-actions-right">
1148
+ <button type="button" class="btn btn-sm btn-primary" id="btn-refresh-models" onclick="refreshModels(this)">
1149
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="refresh-icon">
1150
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
1151
+ <path d="M3 3v5h5"/>
1152
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
1153
+ <path d="M16 16h5v5"/>
1154
+ </svg>
1155
+ <span>Refresh Models</span>
1156
+ </button>
1157
+ </div>
1158
+ </div>
1159
+ </div>
1160
+ </div>
1161
+
1162
+ <!-- Models by Provider -->
1163
+ <% @all_models_by_provider.each do |provider, models| %>
1164
+ <% active_count = models.count(&:active?) %>
1165
+ <div class="card models-provider-card" style="margin-bottom: 1rem;">
1166
+ <div class="models-provider-header" onclick="toggleProviderSection(this)" role="button">
1167
+ <div class="models-provider-left">
1168
+ <svg class="models-collapse-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1169
+ <polyline points="6 9 12 15 18 9"/>
1170
+ </svg>
1171
+ <span class="models-provider-name"><%= provider.titleize %></span>
1172
+ <span class="models-provider-count"><%= active_count %>/<%= models.count %> active</span>
1173
+ </div>
1174
+ <div class="models-provider-actions" onclick="event.stopPropagation();">
1175
+ <%= button_to bulk_toggle_ai_models_admin_settings_path(action_type: "activate", provider: provider),
1176
+ method: :patch,
1177
+ class: "btn btn-xs btn-ghost",
1178
+ title: "Activate all #{provider} models",
1179
+ data: { turbo: false } do %>
1180
+ All On
1181
+ <% end %>
1182
+ <%= button_to bulk_toggle_ai_models_admin_settings_path(action_type: "deactivate", provider: provider),
1183
+ method: :patch,
1184
+ class: "btn btn-xs btn-ghost",
1185
+ title: "Deactivate all #{provider} models",
1186
+ data: { turbo: false } do %>
1187
+ All Off
1188
+ <% end %>
1189
+ </div>
1190
+ </div>
1191
+ <div class="models-provider-content">
1192
+ <% models.group_by(&:model_type).each do |type, type_models| %>
1193
+ <div class="models-type-group">
1194
+ <div class="models-type-label"><%= type&.titleize || 'Other' %></div>
1195
+ <% type_models.each do |model| %>
1196
+ <div class="model-item <%= model.active? ? 'active' : 'inactive' %>">
1197
+ <div class="model-info">
1198
+ <div class="model-name">
1199
+ <%= model.display_name %>
1200
+ <% if model.supports_vision? %>
1201
+ <span class="model-badge model-badge-vision" title="Supports vision/images">Vision</span>
1202
+ <% end %>
1203
+ <% if model.supports_functions? %>
1204
+ <span class="model-badge model-badge-functions" title="Supports function calling">Functions</span>
1205
+ <% end %>
1206
+ </div>
1207
+ <div class="model-meta">
1208
+ <span class="model-id"><%= model.model_id %></span>
1209
+ <% if model.context_window.present? %>
1210
+ <span class="model-context"><%= number_to_human(model.context_window) %> ctx</span>
1211
+ <% end %>
1212
+ <% if model.price_info.present? %>
1213
+ <span class="model-price"><%= model.price_info %></span>
1214
+ <% end %>
1215
+ </div>
1216
+ <% if params[:debug] %>
1217
+ <div class="model-modalities">
1218
+ <span class="modality-label">in:</span>
1219
+ <% Array(model.input_modalities).each do |m| %>
1220
+ <span class="model-badge model-badge-modality"><%= m %></span>
1221
+ <% end %>
1222
+ <span class="modality-label">out:</span>
1223
+ <% Array(model.output_modalities).each do |m| %>
1224
+ <span class="model-badge model-badge-modality"><%= m %></span>
1225
+ <% end %>
1226
+ </div>
1227
+ <% end %>
1228
+ </div>
1229
+ <div class="model-actions">
1230
+ <button type="button"
1231
+ class="model-toggle-btn <%= model.active? ? 'active' : '' %>"
1232
+ title="<%= model.active? ? 'Click to deactivate' : 'Click to activate' %>"
1233
+ data-model-id="<%= model.id %>"
1234
+ data-provider="<%= model.provider %>"
1235
+ onclick="toggleModel(this)">
1236
+ <span class="toggle-track">
1237
+ <span class="toggle-thumb"></span>
1238
+ </span>
1239
+ </button>
1240
+ <button type="button"
1241
+ class="model-delete-btn"
1242
+ title="Delete model"
1243
+ data-model-id="<%= model.id %>"
1244
+ data-provider="<%= model.provider %>"
1245
+ onclick="deleteModel(this)">
1246
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1247
+ <polyline points="3 6 5 6 21 6"/>
1248
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
1249
+ </svg>
1250
+ </button>
1251
+ </div>
1252
+ </div>
1253
+ <% end %>
1254
+ </div>
1255
+ <% end %>
1256
+ </div>
1257
+ </div>
1258
+ <% end %>
1259
+
1260
+ <script>
1261
+ function toggleProviderSection(header) {
1262
+ const card = header.closest('.models-provider-card');
1263
+ card.classList.toggle('collapsed');
1264
+ }
1265
+
1266
+ async function toggleModel(btn) {
1267
+ const modelId = btn.dataset.modelId;
1268
+ const provider = btn.dataset.provider;
1269
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1270
+
1271
+ btn.disabled = true;
1272
+ btn.style.opacity = '0.5';
1273
+
1274
+ try {
1275
+ const response = await fetch('<%= toggle_ai_model_admin_settings_path %>', {
1276
+ method: 'PATCH',
1277
+ headers: {
1278
+ 'Content-Type': 'application/json',
1279
+ 'X-CSRF-Token': csrfToken,
1280
+ 'Accept': 'application/json'
1281
+ },
1282
+ body: JSON.stringify({ model_id: modelId })
1283
+ });
1284
+
1285
+ const data = await response.json();
1286
+
1287
+ if (data.success) {
1288
+ // Toggle button state
1289
+ btn.classList.toggle('active', data.active);
1290
+ btn.title = data.active ? 'Click to deactivate' : 'Click to activate';
1291
+
1292
+ // Toggle row state
1293
+ const row = btn.closest('.model-item');
1294
+ row.classList.toggle('active', data.active);
1295
+ row.classList.toggle('inactive', !data.active);
1296
+
1297
+ // Update provider count
1298
+ updateProviderCount(provider);
1299
+ }
1300
+ } catch (error) {
1301
+ console.error('Toggle failed:', error);
1302
+ } finally {
1303
+ btn.disabled = false;
1304
+ btn.style.opacity = '';
1305
+ }
1306
+ }
1307
+
1308
+ function updateProviderCount(provider) {
1309
+ const cards = document.querySelectorAll('.models-provider-card');
1310
+ cards.forEach(card => {
1311
+ const header = card.querySelector('.models-provider-header');
1312
+ const providerName = header.querySelector('.models-provider-name').textContent.toLowerCase();
1313
+
1314
+ if (providerName === provider) {
1315
+ const items = card.querySelectorAll('.model-item');
1316
+ const activeItems = card.querySelectorAll('.model-item.active');
1317
+ const countEl = header.querySelector('.models-provider-count');
1318
+ countEl.textContent = `${activeItems.length}/${items.length} active`;
1319
+ }
1320
+ });
1321
+ }
1322
+
1323
+ function filterModels(query) {
1324
+ const searchTerm = query.toLowerCase().trim();
1325
+ const items = document.querySelectorAll('.model-item');
1326
+ const typeGroups = document.querySelectorAll('.models-type-group');
1327
+ const providerCards = document.querySelectorAll('.models-provider-card');
1328
+ let visibleCount = 0;
1329
+ let totalCount = items.length;
1330
+
1331
+ // Filter individual model items
1332
+ items.forEach(item => {
1333
+ const name = item.querySelector('.model-name')?.textContent.toLowerCase() || '';
1334
+ const id = item.querySelector('.model-id')?.textContent.toLowerCase() || '';
1335
+ const matches = !searchTerm || name.includes(searchTerm) || id.includes(searchTerm);
1336
+
1337
+ item.style.display = matches ? '' : 'none';
1338
+ if (matches) visibleCount++;
1339
+ });
1340
+
1341
+ // Hide empty type groups
1342
+ typeGroups.forEach(group => {
1343
+ const visibleItems = group.querySelectorAll('.model-item[style=""], .model-item:not([style*="display: none"])');
1344
+ const hasVisible = Array.from(group.querySelectorAll('.model-item')).some(item => item.style.display !== 'none');
1345
+ group.style.display = hasVisible ? '' : 'none';
1346
+ });
1347
+
1348
+ // Hide empty provider cards and update counts
1349
+ providerCards.forEach(card => {
1350
+ const cardItems = card.querySelectorAll('.model-item');
1351
+ const visibleCardItems = Array.from(cardItems).filter(item => item.style.display !== 'none');
1352
+ const hasVisible = visibleCardItems.length > 0;
1353
+
1354
+ card.style.display = hasVisible ? '' : 'none';
1355
+
1356
+ // Expand cards when searching
1357
+ if (searchTerm && hasVisible) {
1358
+ card.classList.remove('collapsed');
1359
+ }
1360
+ });
1361
+
1362
+ // Update search count
1363
+ const countEl = document.getElementById('models-search-count');
1364
+ if (searchTerm) {
1365
+ countEl.textContent = `${visibleCount} of ${totalCount}`;
1366
+ countEl.style.display = '';
1367
+ } else {
1368
+ countEl.style.display = 'none';
1369
+ }
1370
+ }
1371
+
1372
+ async function refreshModels(btn) {
1373
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1374
+ const icon = btn.querySelector('.refresh-icon');
1375
+ const text = btn.querySelector('span');
1376
+
1377
+ btn.disabled = true;
1378
+ icon.style.animation = 'spin 1s linear infinite';
1379
+ text.textContent = 'Syncing...';
1380
+
1381
+ try {
1382
+ const response = await fetch('<%= sync_ai_models_admin_settings_path %>', {
1383
+ method: 'POST',
1384
+ headers: {
1385
+ 'Content-Type': 'application/json',
1386
+ 'X-CSRF-Token': csrfToken,
1387
+ 'Accept': 'application/json'
1388
+ }
1389
+ });
1390
+
1391
+ const data = await response.json();
1392
+
1393
+ if (data.success) {
1394
+ text.textContent = `Synced ${data.count} models!`;
1395
+ setTimeout(() => {
1396
+ window.location.reload();
1397
+ }, 1000);
1398
+ } else {
1399
+ text.textContent = 'Failed';
1400
+ btn.disabled = false;
1401
+ icon.style.animation = '';
1402
+ setTimeout(() => {
1403
+ text.textContent = 'Refresh Models';
1404
+ }, 2000);
1405
+ }
1406
+ } catch (error) {
1407
+ console.error('Refresh failed:', error);
1408
+ text.textContent = 'Error';
1409
+ btn.disabled = false;
1410
+ icon.style.animation = '';
1411
+ setTimeout(() => {
1412
+ text.textContent = 'Refresh Models';
1413
+ }, 2000);
1414
+ }
1415
+ }
1416
+
1417
+ async function deleteModel(btn) {
1418
+ if (!confirm('Delete this model? This cannot be undone.')) return;
1419
+
1420
+ const modelId = btn.dataset.modelId;
1421
+ const provider = btn.dataset.provider;
1422
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1423
+
1424
+ btn.disabled = true;
1425
+ btn.style.opacity = '0.5';
1426
+
1427
+ try {
1428
+ const response = await fetch('<%= destroy_ai_model_admin_settings_path %>', {
1429
+ method: 'DELETE',
1430
+ headers: {
1431
+ 'Content-Type': 'application/json',
1432
+ 'X-CSRF-Token': csrfToken,
1433
+ 'Accept': 'application/json'
1434
+ },
1435
+ body: JSON.stringify({ model_id: modelId })
1436
+ });
1437
+
1438
+ const data = await response.json();
1439
+
1440
+ if (data.success) {
1441
+ const row = btn.closest('.model-item');
1442
+ row.remove();
1443
+ updateProviderCount(provider);
1444
+ }
1445
+ } catch (error) {
1446
+ console.error('Delete failed:', error);
1447
+ btn.disabled = false;
1448
+ btn.style.opacity = '';
1449
+ }
1450
+ }
1451
+
1452
+ // Start with sections expanded
1453
+ document.addEventListener('DOMContentLoaded', function() {
1454
+ // Optionally collapse by default if many providers
1455
+ const cards = document.querySelectorAll('.models-provider-card');
1456
+ if (cards.length > 2) {
1457
+ cards.forEach((card, i) => {
1458
+ if (i > 0) card.classList.add('collapsed');
1459
+ });
1460
+ }
1461
+ });
1462
+ </script>
1463
+ <% else %>
1464
+ <div class="card">
1465
+ <div class="card-body" style="text-align: center; padding: 3rem;">
1466
+ <div style="margin-bottom: 1rem;">
1467
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--text-tertiary);">
1468
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
1469
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
1470
+ <line x1="12" y1="22.08" x2="12" y2="12"/>
1471
+ </svg>
1472
+ </div>
1473
+ <h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">No Models Synced</h3>
1474
+ <p class="help-text" style="margin-bottom: 1.5rem;">Sync models from your AI providers to manage them here.</p>
1475
+ <% if ActiveCanvas::AiConfiguration.configured? %>
1476
+ <%= button_to sync_ai_models_admin_settings_path,
1477
+ method: :post,
1478
+ class: "btn btn-primary",
1479
+ data: { turbo: false } do %>
1480
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 0.5rem;">
1481
+ <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
1482
+ <path d="M3 3v5h5"/>
1483
+ <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
1484
+ <path d="M16 16h5v5"/>
1485
+ </svg>
1486
+ Sync Models Now
1487
+ <% end %>
1488
+ <% else %>
1489
+ <p class="help-text">Configure API keys in the <a href="<%= admin_settings_path(tab: 'ai') %>">AI Assistant</a> tab first.</p>
1490
+ <% end %>
1491
+ </div>
1492
+ </div>
1493
+ <% end %>
1494
+
1495
+ <style>
1496
+ /* Search */
1497
+ .models-toolbar {
1498
+ margin-bottom: 1rem;
1499
+ padding-bottom: 1rem;
1500
+ border-bottom: 1px solid var(--border-color);
1501
+ }
1502
+ .models-search {
1503
+ display: flex;
1504
+ align-items: center;
1505
+ gap: 0.75rem;
1506
+ background: var(--bg-secondary, #f1f5f9);
1507
+ border: 1px solid var(--border-color);
1508
+ border-radius: 8px;
1509
+ padding: 0.5rem 1rem;
1510
+ max-width: 400px;
1511
+ }
1512
+ .models-search svg {
1513
+ color: var(--text-tertiary);
1514
+ flex-shrink: 0;
1515
+ }
1516
+ .models-search input {
1517
+ flex: 1;
1518
+ border: none;
1519
+ background: transparent;
1520
+ font-size: 0.875rem;
1521
+ color: var(--text-primary);
1522
+ outline: none;
1523
+ }
1524
+ .models-search input::placeholder {
1525
+ color: var(--text-tertiary);
1526
+ }
1527
+ .models-search-count {
1528
+ font-size: 0.75rem;
1529
+ color: var(--text-secondary);
1530
+ white-space: nowrap;
1531
+ display: none;
1532
+ }
1533
+
1534
+ /* Bulk Actions */
1535
+ .bulk-actions {
1536
+ display: flex;
1537
+ align-items: center;
1538
+ justify-content: space-between;
1539
+ gap: 1rem;
1540
+ }
1541
+ .bulk-actions-left {
1542
+ display: flex;
1543
+ align-items: center;
1544
+ gap: 1rem;
1545
+ }
1546
+ .bulk-actions-right {
1547
+ display: flex;
1548
+ align-items: center;
1549
+ gap: 0.5rem;
1550
+ }
1551
+ .bulk-actions-label {
1552
+ font-size: 0.8125rem;
1553
+ font-weight: 500;
1554
+ color: var(--text-secondary);
1555
+ }
1556
+ .bulk-actions-buttons {
1557
+ display: flex;
1558
+ gap: 0.5rem;
1559
+ }
1560
+ .refresh-icon {
1561
+ transition: transform 0.3s ease;
1562
+ }
1563
+ #btn-refresh-models:hover .refresh-icon {
1564
+ transform: rotate(180deg);
1565
+ }
1566
+ @keyframes spin {
1567
+ to { transform: rotate(360deg); }
1568
+ }
1569
+ .btn-sm {
1570
+ padding: 0.375rem 0.75rem;
1571
+ font-size: 0.75rem;
1572
+ }
1573
+ .btn-sm svg {
1574
+ margin-right: 0.375rem;
1575
+ }
1576
+ .btn-xs {
1577
+ padding: 0.25rem 0.5rem;
1578
+ font-size: 0.6875rem;
1579
+ }
1580
+ .btn-ghost {
1581
+ background: transparent;
1582
+ border: 1px solid var(--border-color);
1583
+ color: var(--text-secondary);
1584
+ }
1585
+ .btn-ghost:hover {
1586
+ background: var(--bg-secondary);
1587
+ color: var(--text-primary);
1588
+ }
1589
+
1590
+ /* Provider Cards */
1591
+ .models-provider-card.card {
1592
+ overflow: visible;
1593
+ }
1594
+ .models-provider-header {
1595
+ display: flex;
1596
+ align-items: center;
1597
+ justify-content: space-between;
1598
+ padding: 1rem 1.5rem;
1599
+ background: var(--bg-secondary);
1600
+ cursor: pointer;
1601
+ user-select: none;
1602
+ transition: background 0.15s ease;
1603
+ }
1604
+ .models-provider-header:hover {
1605
+ background: var(--bg-tertiary, rgba(255,255,255,0.05));
1606
+ }
1607
+ .models-provider-left {
1608
+ display: flex;
1609
+ align-items: center;
1610
+ gap: 0.75rem;
1611
+ }
1612
+ .models-collapse-icon {
1613
+ color: var(--text-secondary);
1614
+ transition: transform 0.2s ease;
1615
+ }
1616
+ .models-provider-card.collapsed .models-collapse-icon {
1617
+ transform: rotate(-90deg);
1618
+ }
1619
+ .models-provider-name {
1620
+ font-weight: 600;
1621
+ color: var(--text-primary);
1622
+ font-size: 0.9375rem;
1623
+ }
1624
+ .models-provider-count {
1625
+ font-size: 0.75rem;
1626
+ color: var(--text-secondary);
1627
+ background: var(--bg-tertiary, rgba(255,255,255,0.05));
1628
+ padding: 0.125rem 0.5rem;
1629
+ border-radius: 9999px;
1630
+ }
1631
+ .models-provider-actions {
1632
+ display: flex;
1633
+ gap: 0.5rem;
1634
+ }
1635
+ .models-provider-content {
1636
+ padding: 0.5rem 0;
1637
+ border-top: 1px solid var(--border-color);
1638
+ }
1639
+ .models-provider-card.collapsed .models-provider-content {
1640
+ display: none;
1641
+ }
1642
+ .models-type-group {
1643
+ padding: 0.5rem 1.5rem;
1644
+ }
1645
+ .models-type-label {
1646
+ font-size: 0.6875rem;
1647
+ font-weight: 600;
1648
+ text-transform: uppercase;
1649
+ letter-spacing: 0.05em;
1650
+ color: var(--text-tertiary);
1651
+ margin-bottom: 0.5rem;
1652
+ padding-left: 0.25rem;
1653
+ }
1654
+ .model-item {
1655
+ display: flex;
1656
+ align-items: center;
1657
+ justify-content: space-between;
1658
+ padding: 0.75rem 1rem;
1659
+ border-radius: 8px;
1660
+ margin-bottom: 0.25rem;
1661
+ transition: background 0.15s ease;
1662
+ }
1663
+ .model-item:hover {
1664
+ background: var(--bg-secondary);
1665
+ }
1666
+ .model-item.inactive {
1667
+ opacity: 0.5;
1668
+ }
1669
+ .model-item.inactive:hover {
1670
+ opacity: 0.75;
1671
+ }
1672
+ .model-info {
1673
+ flex: 1;
1674
+ min-width: 0;
1675
+ }
1676
+ .model-name {
1677
+ display: flex;
1678
+ align-items: center;
1679
+ gap: 0.5rem;
1680
+ font-weight: 500;
1681
+ color: var(--text-primary);
1682
+ font-size: 0.875rem;
1683
+ }
1684
+ .model-badge {
1685
+ font-size: 0.625rem;
1686
+ font-weight: 600;
1687
+ padding: 0.125rem 0.375rem;
1688
+ border-radius: 4px;
1689
+ text-transform: uppercase;
1690
+ }
1691
+ .model-badge-vision {
1692
+ background: rgba(147, 51, 234, 0.15);
1693
+ color: #a855f7;
1694
+ }
1695
+ .model-badge-functions {
1696
+ background: rgba(59, 130, 246, 0.15);
1697
+ color: #3b82f6;
1698
+ }
1699
+ .model-badge-modality {
1700
+ background: rgba(107, 114, 128, 0.15);
1701
+ color: #9ca3af;
1702
+ font-size: 0.6rem;
1703
+ }
1704
+ .model-modalities {
1705
+ display: flex;
1706
+ align-items: center;
1707
+ gap: 0.25rem;
1708
+ margin-top: 0.2rem;
1709
+ }
1710
+ .modality-label {
1711
+ font-size: 0.6rem;
1712
+ color: #6b7280;
1713
+ font-weight: 600;
1714
+ text-transform: uppercase;
1715
+ }
1716
+ .model-meta {
1717
+ display: flex;
1718
+ align-items: center;
1719
+ gap: 1rem;
1720
+ margin-top: 0.25rem;
1721
+ font-size: 0.75rem;
1722
+ color: var(--text-secondary);
1723
+ }
1724
+ .model-id {
1725
+ font-family: monospace;
1726
+ color: var(--text-tertiary);
1727
+ }
1728
+ .model-actions {
1729
+ display: flex;
1730
+ align-items: center;
1731
+ gap: 0.5rem;
1732
+ flex-shrink: 0;
1733
+ margin-left: 1rem;
1734
+ }
1735
+ .model-toggle-btn {
1736
+ display: flex;
1737
+ align-items: center;
1738
+ justify-content: center;
1739
+ background: transparent;
1740
+ border: none;
1741
+ padding: 0;
1742
+ cursor: pointer;
1743
+ }
1744
+ .toggle-track {
1745
+ position: relative;
1746
+ width: 40px;
1747
+ height: 22px;
1748
+ background: var(--bg-tertiary, #374151);
1749
+ border-radius: 11px;
1750
+ transition: background 0.2s ease;
1751
+ }
1752
+ .model-toggle-btn.active .toggle-track {
1753
+ background: var(--primary-color, #6366f1);
1754
+ }
1755
+ .toggle-thumb {
1756
+ position: absolute;
1757
+ top: 2px;
1758
+ left: 2px;
1759
+ width: 18px;
1760
+ height: 18px;
1761
+ background: white;
1762
+ border-radius: 50%;
1763
+ transition: transform 0.2s ease;
1764
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
1765
+ }
1766
+ .model-toggle-btn.active .toggle-thumb {
1767
+ transform: translateX(18px);
1768
+ }
1769
+ /* Add Custom Model */
1770
+ .add-model-toggle {
1771
+ display: inline-flex;
1772
+ align-items: center;
1773
+ gap: 0.5rem;
1774
+ background: transparent;
1775
+ border: 1px dashed var(--border-color);
1776
+ border-radius: 6px;
1777
+ padding: 0.5rem 1rem;
1778
+ font-size: 0.8125rem;
1779
+ font-weight: 500;
1780
+ color: var(--text-secondary);
1781
+ cursor: pointer;
1782
+ transition: all 0.15s ease;
1783
+ }
1784
+ .add-model-toggle:hover {
1785
+ color: var(--primary-color, #6366f1);
1786
+ border-color: var(--primary-color, #6366f1);
1787
+ background: rgba(99, 102, 241, 0.05);
1788
+ }
1789
+ .add-model-icon {
1790
+ transition: transform 0.2s ease;
1791
+ }
1792
+ .add-model-form {
1793
+ margin-top: 1rem;
1794
+ padding-top: 1rem;
1795
+ border-top: 1px solid var(--border-color);
1796
+ }
1797
+ .add-model-grid {
1798
+ display: grid;
1799
+ grid-template-columns: 1fr 1fr;
1800
+ gap: 1rem;
1801
+ margin-bottom: 0.5rem;
1802
+ }
1803
+ .modality-checkboxes {
1804
+ display: flex;
1805
+ flex-wrap: wrap;
1806
+ gap: 1rem;
1807
+ }
1808
+ .modality-check {
1809
+ display: inline-flex;
1810
+ align-items: center;
1811
+ gap: 0.375rem;
1812
+ font-size: 0.8125rem;
1813
+ color: var(--text-secondary);
1814
+ cursor: pointer;
1815
+ }
1816
+ .modality-check input[type="checkbox"] {
1817
+ accent-color: var(--primary-color, #6366f1);
1818
+ }
1819
+
1820
+ /* Delete Button */
1821
+ .model-delete-btn {
1822
+ display: flex;
1823
+ align-items: center;
1824
+ justify-content: center;
1825
+ background: transparent;
1826
+ border: none;
1827
+ padding: 0.25rem;
1828
+ border-radius: 4px;
1829
+ color: var(--text-tertiary);
1830
+ cursor: pointer;
1831
+ opacity: 0;
1832
+ transition: all 0.15s ease;
1833
+ }
1834
+ .model-item:hover .model-delete-btn {
1835
+ opacity: 1;
1836
+ }
1837
+ .model-delete-btn:hover {
1838
+ color: #ef4444;
1839
+ background: rgba(239, 68, 68, 0.1);
1840
+ }
1841
+
1842
+ @media (max-width: 640px) {
1843
+ .add-model-grid {
1844
+ grid-template-columns: 1fr;
1845
+ }
1846
+ .model-meta {
1847
+ flex-wrap: wrap;
1848
+ gap: 0.5rem;
1849
+ }
1850
+ .model-item {
1851
+ flex-direction: column;
1852
+ align-items: flex-start;
1853
+ gap: 0.75rem;
1854
+ }
1855
+ .model-actions {
1856
+ margin-left: 0;
1857
+ align-self: flex-end;
1858
+ }
1859
+ .model-delete-btn {
1860
+ opacity: 1;
1861
+ }
1862
+ }
1863
+ </style>
1864
+ <% end %>