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,1607 @@
1
+ /**
2
+ * AI Panel Module for ActiveCanvas Editor
3
+ * Handles AI-powered text generation, image generation, and screenshot-to-code
4
+ */
5
+ (function() {
6
+ 'use strict';
7
+
8
+ const STORAGE_PREFIX = 'active_canvas_ai_model_';
9
+
10
+ // Direct mode endpoint map
11
+ const DIRECT_ENDPOINTS = {
12
+ openai: { chat: 'https://api.openai.com/v1/chat/completions',
13
+ image: 'https://api.openai.com/v1/images/generations' },
14
+ openrouter: { chat: 'https://openrouter.ai/api/v1/chat/completions' },
15
+ anthropic: { chat: 'https://api.anthropic.com/v1/messages' }
16
+ };
17
+
18
+ // State
19
+ let aiStatus = null;
20
+ let availableModels = { text: [], image: [], vision: [] };
21
+ let selectedModels = { text: null, image: null, screenshot: null };
22
+ let currentMode = 'page';
23
+ let currentTab = 'text';
24
+ let isGenerating = false;
25
+ let abortController = null;
26
+
27
+ // DOM Elements (cached after init)
28
+ let elements = {};
29
+
30
+ // Model pickers state
31
+ let pickers = {};
32
+
33
+ // ==================== Direct Mode Helpers ====================
34
+
35
+ function isDirectMode() {
36
+ return window.ActiveCanvasEditor?.config?.aiConnectionMode === 'direct';
37
+ }
38
+
39
+ function getModelProvider(modelId) {
40
+ const allModels = [...(availableModels.text || []), ...(availableModels.image || []), ...(availableModels.vision || [])];
41
+ const model = allModels.find(m => m.id === modelId);
42
+ return model?.provider || null;
43
+ }
44
+
45
+ function getApiKey(provider) {
46
+ return window.ActiveCanvasEditor?.config?.aiApiKeys?.[provider] || null;
47
+ }
48
+
49
+ function buildSystemPrompt(context) {
50
+ const framework = window.ActiveCanvasEditor?.config?.cssFramework || 'custom';
51
+ let frameworkGuidelines = '';
52
+
53
+ if (framework === 'tailwind') {
54
+ frameworkGuidelines = `CSS Framework: Tailwind CSS v4
55
+
56
+ You MUST use Tailwind CSS utility classes exclusively for all styling. Do NOT use inline styles or custom CSS.
57
+
58
+ Tailwind v4 rules:
59
+ - Use slash syntax for opacity: bg-blue-500/50, text-black/75 (NOT bg-opacity-50 or text-opacity-75)
60
+ - Use modern color syntax: bg-red-500/20 instead of bg-red-500 bg-opacity-20
61
+ - Use arbitrary values with square brackets when needed: w-[72rem], text-[#1a2b3c]
62
+ - Use the new shadow and ring syntax: shadow-sm, ring-1 ring-gray-200
63
+ - Prefer gap-* over space-x-*/space-y-* for flex and grid layouts
64
+ - Use size-* for equal width and height: size-8 instead of w-8 h-8
65
+ - Use grid with grid-cols-subgrid where appropriate
66
+ - All legacy utilities removed in v4 are forbidden (bg-opacity-*, text-opacity-*, divide-opacity-*, etc.)`;
67
+ } else if (framework === 'bootstrap5') {
68
+ frameworkGuidelines = `CSS Framework: Bootstrap 5
69
+
70
+ Use Bootstrap 5 classes exclusively for all styling. Do NOT use inline styles or custom CSS.
71
+
72
+ Bootstrap 5 rules:
73
+ - Use the grid system: container, row, col-*, col-md-*, col-lg-*
74
+ - Use Bootstrap utility classes: d-flex, justify-content-center, align-items-center, p-3, m-2, etc.
75
+ - Use Bootstrap components: card, btn, navbar, alert, badge, etc.
76
+ - Use responsive breakpoints: sm, md, lg, xl, xxl
77
+ - Use spacing utilities: p-*, m-*, gap-*
78
+ - Use text utilities: text-center, fw-bold, fs-*, text-muted
79
+ - Use background utilities: bg-primary, bg-light, bg-dark, etc.`;
80
+ } else {
81
+ frameworkGuidelines = `CSS Framework: None (vanilla CSS)
82
+
83
+ Use inline styles for all styling since no CSS framework is loaded.
84
+
85
+ Vanilla CSS rules:
86
+ - Apply all styles via the style attribute directly on HTML elements
87
+ - Use modern CSS: flexbox, grid, clamp(), min(), max()
88
+ - Ensure responsive behavior with relative units (%, rem, vw) and media queries via <style> blocks when necessary
89
+ - Use CSS custom properties (variables) in a <style> block for consistent theming`;
90
+ }
91
+
92
+ return `You are an expert web designer creating content for a visual page builder.
93
+ Generate clean, semantic HTML.
94
+
95
+ ${frameworkGuidelines}
96
+
97
+ General guidelines:
98
+ - Use proper semantic HTML5 elements (section, article, header, nav, etc.)
99
+ - Include responsive design patterns
100
+ - Return ONLY the HTML code, no explanations or markdown code blocks
101
+ - Do not include <html>, <head>, or <body> tags - just the content
102
+ - Use placeholder images from https://placehold.co/ when images are needed
103
+
104
+ ${context || ''}`;
105
+ }
106
+
107
+ function buildScreenshotPrompt(additionalPrompt) {
108
+ const framework = window.ActiveCanvasEditor?.config?.cssFramework || 'custom';
109
+ let frameworkGuidelines = '';
110
+
111
+ if (framework === 'tailwind') {
112
+ frameworkGuidelines = 'CSS Framework: Tailwind CSS v4\n\nYou MUST use Tailwind CSS utility classes exclusively for all styling. Do NOT use inline styles or custom CSS.';
113
+ } else if (framework === 'bootstrap5') {
114
+ frameworkGuidelines = 'CSS Framework: Bootstrap 5\n\nUse Bootstrap 5 classes exclusively for all styling. Do NOT use inline styles or custom CSS.';
115
+ } else {
116
+ frameworkGuidelines = 'CSS Framework: None (vanilla CSS)\n\nUse inline styles for all styling since no CSS framework is loaded.';
117
+ }
118
+
119
+ let base = `Convert this screenshot into clean HTML.
120
+
121
+ ${frameworkGuidelines}
122
+
123
+ Requirements:
124
+ - Create semantic, accessible HTML5 structure
125
+ - Make it fully responsive
126
+ - Use placeholder images from https://placehold.co/ for any images
127
+ - Match the layout, colors, and typography as closely as possible
128
+ - Return ONLY the HTML code, no explanations or markdown code blocks
129
+ - Do not include <html>, <head>, or <body> tags - just the content`;
130
+
131
+ if (additionalPrompt) {
132
+ base += `\n\nAdditional instructions: ${additionalPrompt}`;
133
+ }
134
+
135
+ return base;
136
+ }
137
+
138
+ // ==================== Direct Mode SSE Parsers ====================
139
+
140
+ async function processOpenAIStream(body, outputElement) {
141
+ const reader = body.getReader();
142
+ const decoder = new TextDecoder();
143
+ let buffer = '';
144
+ let fullContent = '';
145
+
146
+ outputElement.innerHTML = '';
147
+
148
+ try {
149
+ while (true) {
150
+ const { done, value } = await reader.read();
151
+ if (done) break;
152
+
153
+ buffer += decoder.decode(value, { stream: true });
154
+ const lines = buffer.split('\n');
155
+ buffer = lines.pop() || '';
156
+
157
+ for (const line of lines) {
158
+ if (line.startsWith('data: ')) {
159
+ const payload = line.slice(6).trim();
160
+ if (payload === '[DONE]') return;
161
+
162
+ try {
163
+ const data = JSON.parse(payload);
164
+ const content = data.choices?.[0]?.delta?.content;
165
+ if (content) {
166
+ fullContent += content;
167
+ outputElement.textContent = fullContent;
168
+ outputElement.scrollTop = outputElement.scrollHeight;
169
+ }
170
+ } catch (e) {
171
+ // Ignore incomplete JSON
172
+ }
173
+ }
174
+ }
175
+ }
176
+ } catch (error) {
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ async function processAnthropicStream(body, outputElement) {
182
+ const reader = body.getReader();
183
+ const decoder = new TextDecoder();
184
+ let buffer = '';
185
+ let fullContent = '';
186
+
187
+ outputElement.innerHTML = '';
188
+
189
+ try {
190
+ while (true) {
191
+ const { done, value } = await reader.read();
192
+ if (done) break;
193
+
194
+ buffer += decoder.decode(value, { stream: true });
195
+ const lines = buffer.split('\n');
196
+ buffer = lines.pop() || '';
197
+
198
+ for (const line of lines) {
199
+ if (line.startsWith('data: ')) {
200
+ try {
201
+ const data = JSON.parse(line.slice(6));
202
+
203
+ if (data.type === 'content_block_delta' && data.delta?.type === 'text_delta') {
204
+ fullContent += data.delta.text;
205
+ outputElement.textContent = fullContent;
206
+ outputElement.scrollTop = outputElement.scrollHeight;
207
+ }
208
+
209
+ if (data.type === 'message_stop') return;
210
+
211
+ if (data.type === 'error') {
212
+ showOutputError(outputElement, data.error?.message || 'Anthropic stream error');
213
+ return;
214
+ }
215
+ } catch (e) {
216
+ // Ignore incomplete JSON
217
+ }
218
+ }
219
+ }
220
+ }
221
+ } catch (error) {
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ // ==================== Direct Mode API Calls ====================
227
+
228
+ async function generateTextDirect(prompt, model, currentHtml) {
229
+ const provider = getModelProvider(model);
230
+ if (!provider) {
231
+ throw new Error(`Unknown provider for model ${model}. Check your model configuration.`);
232
+ }
233
+
234
+ const apiKey = getApiKey(provider);
235
+ if (!apiKey) {
236
+ throw new Error(`No API key configured for ${provider}. Add it in Settings > AI.`);
237
+ }
238
+
239
+ const endpoint = DIRECT_ENDPOINTS[provider]?.chat;
240
+ if (!endpoint) {
241
+ throw new Error(`Direct mode not supported for provider: ${provider}`);
242
+ }
243
+
244
+ let context = '';
245
+ if (currentMode === 'element' && currentHtml) {
246
+ context = `The user is editing an existing element. Current HTML:\n${currentHtml}`;
247
+ }
248
+ const systemPrompt = buildSystemPrompt(context);
249
+
250
+ abortController = new AbortController();
251
+
252
+ let response;
253
+
254
+ if (provider === 'anthropic') {
255
+ response = await fetch(endpoint, {
256
+ method: 'POST',
257
+ headers: {
258
+ 'Content-Type': 'application/json',
259
+ 'x-api-key': apiKey,
260
+ 'anthropic-version': '2023-06-01',
261
+ 'anthropic-dangerous-direct-browser-access': 'true'
262
+ },
263
+ body: JSON.stringify({
264
+ model,
265
+ max_tokens: 4096,
266
+ stream: true,
267
+ system: systemPrompt,
268
+ messages: [{ role: 'user', content: prompt }]
269
+ }),
270
+ signal: abortController.signal
271
+ });
272
+ } else {
273
+ // OpenAI / OpenRouter
274
+ const headers = {
275
+ 'Content-Type': 'application/json',
276
+ 'Authorization': `Bearer ${apiKey}`
277
+ };
278
+
279
+ response = await fetch(endpoint, {
280
+ method: 'POST',
281
+ headers,
282
+ body: JSON.stringify({
283
+ model,
284
+ stream: true,
285
+ messages: [
286
+ { role: 'system', content: systemPrompt },
287
+ { role: 'user', content: prompt }
288
+ ]
289
+ }),
290
+ signal: abortController.signal
291
+ });
292
+ }
293
+
294
+ if (!response.ok) {
295
+ if (response.status === 401) {
296
+ throw new Error(`API key may be incorrect for ${provider}.`);
297
+ }
298
+ let errMsg = `Request failed (${response.status})`;
299
+ try {
300
+ const errData = await response.json();
301
+ errMsg = errData.error?.message || errData.error || errMsg;
302
+ } catch (e) {}
303
+ throw new Error(errMsg);
304
+ }
305
+
306
+ if (provider === 'anthropic') {
307
+ await processAnthropicStream(response.body, elements.textOutput);
308
+ } else {
309
+ await processOpenAIStream(response.body, elements.textOutput);
310
+ }
311
+ }
312
+
313
+ async function generateImageDirect(prompt, model) {
314
+ const provider = getModelProvider(model);
315
+
316
+ // Only OpenAI supports image generation in direct mode
317
+ if (provider !== 'openai') {
318
+ throw new Error(`Direct image generation only supported for OpenAI models. Switch to server mode for ${provider}.`);
319
+ }
320
+
321
+ const apiKey = getApiKey('openai');
322
+ if (!apiKey) {
323
+ throw new Error('No API key configured for openai. Add it in Settings > AI.');
324
+ }
325
+
326
+ abortController = new AbortController();
327
+
328
+ const response = await fetch(DIRECT_ENDPOINTS.openai.image, {
329
+ method: 'POST',
330
+ headers: {
331
+ 'Content-Type': 'application/json',
332
+ 'Authorization': `Bearer ${apiKey}`
333
+ },
334
+ body: JSON.stringify({
335
+ model,
336
+ prompt,
337
+ n: 1,
338
+ size: '1024x1024'
339
+ }),
340
+ signal: abortController.signal
341
+ });
342
+
343
+ if (!response.ok) {
344
+ if (response.status === 401) {
345
+ throw new Error('API key may be incorrect for openai.');
346
+ }
347
+ let errMsg = `Image generation failed (${response.status})`;
348
+ try {
349
+ const errData = await response.json();
350
+ errMsg = errData.error?.message || errMsg;
351
+ } catch (e) {}
352
+ throw new Error(errMsg);
353
+ }
354
+
355
+ const data = await response.json();
356
+ return data.data?.[0]?.url || data.data?.[0]?.b64_json;
357
+ }
358
+
359
+ async function convertScreenshotDirect(imageDataUrl, model, additionalPrompt) {
360
+ const provider = getModelProvider(model);
361
+ if (!provider) {
362
+ throw new Error(`Unknown provider for model ${model}.`);
363
+ }
364
+
365
+ const apiKey = getApiKey(provider);
366
+ if (!apiKey) {
367
+ throw new Error(`No API key configured for ${provider}. Add it in Settings > AI.`);
368
+ }
369
+
370
+ const endpoint = DIRECT_ENDPOINTS[provider]?.chat;
371
+ if (!endpoint) {
372
+ throw new Error(`Direct mode not supported for provider: ${provider}`);
373
+ }
374
+
375
+ const screenshotPrompt = buildScreenshotPrompt(additionalPrompt);
376
+ abortController = new AbortController();
377
+
378
+ let response;
379
+
380
+ if (provider === 'anthropic') {
381
+ // Extract base64 data and media type from data URL
382
+ const match = imageDataUrl.match(/^data:(image\/\w+);base64,(.+)$/);
383
+ const mediaType = match ? match[1] : 'image/png';
384
+ const rawBase64 = match ? match[2] : imageDataUrl.replace(/^data:image\/\w+;base64,/, '');
385
+
386
+ response = await fetch(endpoint, {
387
+ method: 'POST',
388
+ headers: {
389
+ 'Content-Type': 'application/json',
390
+ 'x-api-key': apiKey,
391
+ 'anthropic-version': '2023-06-01',
392
+ 'anthropic-dangerous-direct-browser-access': 'true'
393
+ },
394
+ body: JSON.stringify({
395
+ model,
396
+ max_tokens: 4096,
397
+ stream: true,
398
+ messages: [{
399
+ role: 'user',
400
+ content: [
401
+ { type: 'image', source: { type: 'base64', media_type: mediaType, data: rawBase64 } },
402
+ { type: 'text', text: screenshotPrompt }
403
+ ]
404
+ }]
405
+ }),
406
+ signal: abortController.signal
407
+ });
408
+ } else {
409
+ // OpenAI / OpenRouter vision format
410
+ response = await fetch(endpoint, {
411
+ method: 'POST',
412
+ headers: {
413
+ 'Content-Type': 'application/json',
414
+ 'Authorization': `Bearer ${apiKey}`
415
+ },
416
+ body: JSON.stringify({
417
+ model,
418
+ stream: true,
419
+ messages: [{
420
+ role: 'user',
421
+ content: [
422
+ { type: 'text', text: screenshotPrompt },
423
+ { type: 'image_url', image_url: { url: imageDataUrl } }
424
+ ]
425
+ }]
426
+ }),
427
+ signal: abortController.signal
428
+ });
429
+ }
430
+
431
+ if (!response.ok) {
432
+ if (response.status === 401) {
433
+ throw new Error(`API key may be incorrect for ${provider}.`);
434
+ }
435
+ let errMsg = `Screenshot conversion failed (${response.status})`;
436
+ try {
437
+ const errData = await response.json();
438
+ errMsg = errData.error?.message || errData.error || errMsg;
439
+ } catch (e) {}
440
+ throw new Error(errMsg);
441
+ }
442
+
443
+ if (provider === 'anthropic') {
444
+ await processAnthropicStream(response.body, elements.screenshotOutput);
445
+ } else {
446
+ await processOpenAIStream(response.body, elements.screenshotOutput);
447
+ }
448
+ }
449
+
450
+ // ==================== Initialization ====================
451
+
452
+ /**
453
+ * Initialize the AI panel
454
+ */
455
+ function init() {
456
+ if (!window.ActiveCanvasEditor?.config?.aiEndpoints) {
457
+ console.log('AI Panel: No AI endpoints configured');
458
+ return;
459
+ }
460
+
461
+ cacheElements();
462
+ initModelPickers();
463
+ setupEventListeners();
464
+ checkAiStatus();
465
+ setupTextareaAutoResize();
466
+ }
467
+
468
+ /**
469
+ * Cache DOM elements for performance
470
+ */
471
+ function cacheElements() {
472
+ elements = {
473
+ panel: document.getElementById('panel-ai'),
474
+ // Tabs
475
+ tabs: document.querySelectorAll('.ai-tab'),
476
+ tabPanels: document.querySelectorAll('.ai-tab-panel'),
477
+ // Text generation
478
+ textPrompt: document.getElementById('ai-text-prompt'),
479
+ textGenerateBtn: document.getElementById('btn-ai-text-generate'),
480
+ textOutput: document.getElementById('ai-text-output'),
481
+ textOutputWrapper: document.getElementById('ai-text-output-wrapper'),
482
+ textInsertBtn: document.getElementById('btn-ai-text-insert'),
483
+ textConversation: document.getElementById('ai-text-conversation'),
484
+ regenerateBtn: document.getElementById('btn-ai-regenerate'),
485
+ copyBtn: document.getElementById('btn-ai-copy'),
486
+ // Image generation
487
+ imagePrompt: document.getElementById('ai-image-prompt'),
488
+ imageGenerateBtn: document.getElementById('btn-ai-image-generate'),
489
+ imageOutput: document.getElementById('ai-image-output'),
490
+ imageOutputWrapper: document.getElementById('ai-image-output-wrapper'),
491
+ imageInsertBtn: document.getElementById('btn-ai-image-insert'),
492
+ imageEmpty: document.getElementById('ai-image-empty'),
493
+ // Screenshot
494
+ screenshotInput: document.getElementById('ai-screenshot-input'),
495
+ screenshotPreview: document.getElementById('ai-screenshot-preview'),
496
+ screenshotPrompt: document.getElementById('ai-screenshot-prompt'),
497
+ screenshotConvertBtn: document.getElementById('btn-ai-screenshot-convert'),
498
+ screenshotOutput: document.getElementById('ai-screenshot-output'),
499
+ screenshotOutputWrapper: document.getElementById('ai-screenshot-output-wrapper'),
500
+ screenshotInsertBtn: document.getElementById('btn-ai-screenshot-insert'),
501
+ uploadZone: document.getElementById('ai-screenshot-drop'),
502
+ uploadPlaceholder: document.getElementById('ai-upload-placeholder'),
503
+ // Mode selector
504
+ modeButtons: document.querySelectorAll('.ai-mode-toggle .ai-mode-btn'),
505
+ modeSlider: document.querySelector('.ai-mode-slider'),
506
+ elementInfo: document.getElementById('ai-element-info'),
507
+ selectedElement: document.getElementById('ai-selected-element'),
508
+ // Status
509
+ statusBadge: document.getElementById('ai-status-badge'),
510
+ notConfigured: document.getElementById('ai-not-configured'),
511
+ panelTabs: document.getElementById('ai-panel-tabs')
512
+ };
513
+ }
514
+
515
+ /**
516
+ * Initialize model picker components
517
+ */
518
+ function initModelPickers() {
519
+ ['text', 'image', 'screenshot'].forEach(tab => {
520
+ const el = document.getElementById(`ai-${tab}-model-picker`);
521
+ if (!el) return;
522
+
523
+ const picker = {
524
+ root: el,
525
+ btn: el.querySelector('.ai-model-picker-btn'),
526
+ label: el.querySelector('.ai-model-picker-label'),
527
+ dropdown: el.querySelector('.ai-model-picker-dropdown'),
528
+ search: el.querySelector('.ai-model-picker-search'),
529
+ list: el.querySelector('.ai-model-picker-list'),
530
+ open: false,
531
+ tab: tab
532
+ };
533
+
534
+ picker.btn.addEventListener('click', (e) => {
535
+ e.stopPropagation();
536
+ togglePicker(tab);
537
+ });
538
+
539
+ picker.search.addEventListener('input', () => filterModels(tab));
540
+ picker.search.addEventListener('keydown', (e) => handlePickerKeydown(e, tab));
541
+
542
+ pickers[tab] = picker;
543
+ });
544
+
545
+ // Close all pickers on outside click
546
+ document.addEventListener('click', closeAllPickers);
547
+ }
548
+
549
+ /**
550
+ * Toggle a model picker open/closed
551
+ */
552
+ function togglePicker(tab) {
553
+ const picker = pickers[tab];
554
+ if (!picker) return;
555
+
556
+ if (picker.open) {
557
+ closePicker(tab);
558
+ } else {
559
+ closeAllPickers();
560
+ openPicker(tab);
561
+ }
562
+ }
563
+
564
+ function openPicker(tab) {
565
+ const picker = pickers[tab];
566
+ if (!picker) return;
567
+
568
+ picker.open = true;
569
+ picker.root.classList.add('open');
570
+ picker.search.value = '';
571
+ filterModels(tab);
572
+ picker.search.focus();
573
+ }
574
+
575
+ function closePicker(tab) {
576
+ const picker = pickers[tab];
577
+ if (!picker) return;
578
+
579
+ picker.open = false;
580
+ picker.root.classList.remove('open');
581
+ }
582
+
583
+ function closeAllPickers() {
584
+ Object.keys(pickers).forEach(closePicker);
585
+ }
586
+
587
+ /**
588
+ * Filter models in a picker based on search input
589
+ */
590
+ function filterModels(tab) {
591
+ const picker = pickers[tab];
592
+ if (!picker) return;
593
+
594
+ const query = picker.search.value.toLowerCase().trim();
595
+ const modelKey = tab === 'screenshot' ? 'vision' : tab;
596
+ const models = availableModels[modelKey] || [];
597
+
598
+ const filtered = query
599
+ ? models.filter(m => m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query) || (m.provider || '').toLowerCase().includes(query))
600
+ : models;
601
+
602
+ renderPickerList(tab, filtered);
603
+ }
604
+
605
+ /**
606
+ * Render the model list in a picker
607
+ */
608
+ function renderPickerList(tab, models) {
609
+ const picker = pickers[tab];
610
+ if (!picker) return;
611
+
612
+ if (!models.length) {
613
+ picker.list.innerHTML = '<li class="ai-model-picker-empty">No models found</li>';
614
+ return;
615
+ }
616
+
617
+ picker.list.innerHTML = models.map(m => {
618
+ const isSelected = selectedModels[tab] === m.id;
619
+ return `<li class="ai-model-picker-item${isSelected ? ' selected' : ''}" data-value="${escapeHtml(m.id)}" role="option" aria-selected="${isSelected}">
620
+ <span class="ai-model-picker-item-name">${escapeHtml(m.name)}</span>
621
+ <span class="ai-model-picker-item-provider">${escapeHtml(m.provider || '')}</span>
622
+ </li>`;
623
+ }).join('');
624
+
625
+ // Bind click on each item
626
+ picker.list.querySelectorAll('.ai-model-picker-item').forEach(item => {
627
+ item.addEventListener('click', (e) => {
628
+ e.stopPropagation();
629
+ selectModel(tab, item.dataset.value);
630
+ });
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Select a model for a tab
636
+ */
637
+ function selectModel(tab, modelId) {
638
+ const modelKey = tab === 'screenshot' ? 'vision' : tab;
639
+ const models = availableModels[modelKey] || [];
640
+ const model = models.find(m => m.id === modelId);
641
+
642
+ selectedModels[tab] = modelId;
643
+
644
+ // Update button label
645
+ const picker = pickers[tab];
646
+ if (picker && model) {
647
+ picker.label.textContent = model.name;
648
+ }
649
+
650
+ // Persist to localStorage
651
+ try {
652
+ localStorage.setItem(STORAGE_PREFIX + tab, modelId);
653
+ } catch (e) {
654
+ // localStorage not available
655
+ }
656
+
657
+ closePicker(tab);
658
+ }
659
+
660
+ /**
661
+ * Get the currently selected model ID for a tab
662
+ */
663
+ function getSelectedModel(tab) {
664
+ return selectedModels[tab] || null;
665
+ }
666
+
667
+ /**
668
+ * Handle keyboard navigation inside picker
669
+ */
670
+ function handlePickerKeydown(e, tab) {
671
+ const picker = pickers[tab];
672
+ if (!picker) return;
673
+
674
+ if (e.key === 'Escape') {
675
+ closePicker(tab);
676
+ picker.btn.focus();
677
+ } else if (e.key === 'Enter') {
678
+ e.preventDefault();
679
+ const first = picker.list.querySelector('.ai-model-picker-item');
680
+ if (first) selectModel(tab, first.dataset.value);
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Setup auto-resize for textareas
686
+ */
687
+ function setupTextareaAutoResize() {
688
+ const textareas = [elements.textPrompt, elements.imagePrompt, elements.screenshotPrompt];
689
+
690
+ textareas.forEach(textarea => {
691
+ if (!textarea) return;
692
+
693
+ textarea.addEventListener('input', function() {
694
+ this.style.height = 'auto';
695
+ this.style.height = Math.min(this.scrollHeight, 120) + 'px';
696
+ });
697
+ });
698
+ }
699
+
700
+ /**
701
+ * Check AI configuration status
702
+ */
703
+ async function checkAiStatus() {
704
+ const endpoints = window.ActiveCanvasEditor.config.aiEndpoints;
705
+ if (!endpoints?.status) return;
706
+
707
+ try {
708
+ const response = await fetch(endpoints.status);
709
+ aiStatus = await response.json();
710
+
711
+ updateStatusUI();
712
+ if (aiStatus.configured) {
713
+ loadAvailableModels();
714
+ }
715
+ } catch (error) {
716
+ console.error('AI Panel: Failed to check status', error);
717
+ showNotConfiguredState('Unable to connect to AI service');
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Load available models based on configured providers
723
+ */
724
+ async function loadAvailableModels() {
725
+ const endpoints = window.ActiveCanvasEditor.config.aiEndpoints;
726
+ if (!endpoints?.models) return;
727
+
728
+ try {
729
+ const response = await fetch(endpoints.models);
730
+ const data = await response.json();
731
+
732
+ availableModels = {
733
+ text: data.text || [],
734
+ image: data.image || [],
735
+ vision: data.vision || []
736
+ };
737
+
738
+ populateModelPickers(data.default_text, data.default_image, data.default_vision);
739
+ } catch (error) {
740
+ console.error('AI Panel: Failed to load models', error);
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Populate model pickers with loaded data, restoring last-used from localStorage
746
+ */
747
+ function populateModelPickers(defaultText, defaultImage, defaultVision) {
748
+ const defaults = { text: defaultText, image: defaultImage, screenshot: defaultVision };
749
+ const modelKeys = { text: 'text', image: 'image', screenshot: 'vision' };
750
+
751
+ ['text', 'image', 'screenshot'].forEach(tab => {
752
+ const models = availableModels[modelKeys[tab]] || [];
753
+ if (!models.length) return;
754
+
755
+ // Determine which model to select: localStorage > server default > first
756
+ let saved = null;
757
+ try { saved = localStorage.getItem(STORAGE_PREFIX + tab); } catch (e) {}
758
+
759
+ const serverDefault = defaults[tab];
760
+ const modelIds = models.map(m => m.id);
761
+
762
+ let chosen = null;
763
+ if (saved && modelIds.includes(saved)) {
764
+ chosen = saved;
765
+ } else if (serverDefault && modelIds.includes(serverDefault)) {
766
+ chosen = serverDefault;
767
+ } else {
768
+ chosen = modelIds[0];
769
+ }
770
+
771
+ selectedModels[tab] = chosen;
772
+
773
+ // Update picker label
774
+ const picker = pickers[tab];
775
+ if (picker) {
776
+ const model = models.find(m => m.id === chosen);
777
+ picker.label.textContent = model ? model.name : chosen;
778
+ renderPickerList(tab, models);
779
+ }
780
+ });
781
+ }
782
+
783
+ /**
784
+ * Update UI based on status
785
+ */
786
+ function updateStatusUI() {
787
+ if (!aiStatus) return;
788
+
789
+ // Show/hide not configured state
790
+ if (elements.notConfigured) {
791
+ elements.notConfigured.style.display = aiStatus.configured ? 'none' : 'flex';
792
+ }
793
+
794
+ if (elements.panelTabs) {
795
+ elements.panelTabs.style.display = aiStatus.configured ? 'flex' : 'none';
796
+ }
797
+
798
+ // Hide all tab panels if not configured
799
+ elements.tabPanels.forEach(panel => {
800
+ panel.style.display = aiStatus.configured && panel.dataset.tab === currentTab ? 'flex' : 'none';
801
+ panel.classList.toggle('active', aiStatus.configured && panel.dataset.tab === currentTab);
802
+ });
803
+
804
+ updateTabStates();
805
+ }
806
+
807
+ /**
808
+ * Update tab enabled states based on feature toggles
809
+ */
810
+ function updateTabStates() {
811
+ if (!aiStatus) return;
812
+
813
+ elements.tabs.forEach(tab => {
814
+ const feature = tab.dataset.tab;
815
+ let enabled = false;
816
+
817
+ switch (feature) {
818
+ case 'text':
819
+ enabled = aiStatus.text_enabled;
820
+ break;
821
+ case 'image':
822
+ enabled = aiStatus.image_enabled;
823
+ break;
824
+ case 'screenshot':
825
+ enabled = aiStatus.screenshot_enabled;
826
+ break;
827
+ }
828
+
829
+ tab.classList.toggle('disabled', !enabled);
830
+ tab.disabled = !enabled;
831
+ if (!enabled) {
832
+ tab.title = 'This feature is disabled in settings';
833
+ }
834
+ });
835
+ }
836
+
837
+ /**
838
+ * Show not configured state
839
+ */
840
+ function showNotConfiguredState(message) {
841
+ if (elements.notConfigured) {
842
+ elements.notConfigured.style.display = 'flex';
843
+ const msgEl = elements.notConfigured.querySelector('p');
844
+ if (msgEl && message) msgEl.textContent = message;
845
+ }
846
+ if (elements.panelTabs) {
847
+ elements.panelTabs.style.display = 'none';
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Setup event listeners
853
+ */
854
+ function setupEventListeners() {
855
+ // Tab switching
856
+ elements.tabs.forEach(tab => {
857
+ tab.addEventListener('click', () => {
858
+ if (!tab.disabled) {
859
+ switchTab(tab.dataset.tab);
860
+ }
861
+ });
862
+ });
863
+
864
+ // Mode switching
865
+ elements.modeButtons.forEach(btn => {
866
+ btn.addEventListener('click', () => switchMode(btn.dataset.mode));
867
+ });
868
+
869
+ // Text generation
870
+ if (elements.textPrompt) {
871
+ elements.textPrompt.addEventListener('input', updateTextButtonState);
872
+ elements.textPrompt.addEventListener('keydown', handleTextPromptKeydown);
873
+ }
874
+ if (elements.textGenerateBtn) {
875
+ elements.textGenerateBtn.addEventListener('click', generateText);
876
+ }
877
+ if (elements.textInsertBtn) {
878
+ elements.textInsertBtn.addEventListener('click', insertTextContent);
879
+ }
880
+ if (elements.regenerateBtn) {
881
+ elements.regenerateBtn.addEventListener('click', generateText);
882
+ }
883
+ if (elements.copyBtn) {
884
+ elements.copyBtn.addEventListener('click', copyToClipboard);
885
+ }
886
+
887
+ // Image generation
888
+ if (elements.imagePrompt) {
889
+ elements.imagePrompt.addEventListener('input', updateImageButtonState);
890
+ elements.imagePrompt.addEventListener('keydown', handleImagePromptKeydown);
891
+ }
892
+ if (elements.imageGenerateBtn) {
893
+ elements.imageGenerateBtn.addEventListener('click', generateImage);
894
+ }
895
+ if (elements.imageInsertBtn) {
896
+ elements.imageInsertBtn.addEventListener('click', insertImageContent);
897
+ }
898
+
899
+ // Screenshot
900
+ if (elements.screenshotInput) {
901
+ elements.screenshotInput.addEventListener('change', handleScreenshotUpload);
902
+ }
903
+ if (elements.uploadZone) {
904
+ elements.uploadZone.addEventListener('dragover', handleDragOver);
905
+ elements.uploadZone.addEventListener('dragleave', handleDragLeave);
906
+ elements.uploadZone.addEventListener('drop', handleDrop);
907
+ }
908
+ if (elements.screenshotConvertBtn) {
909
+ elements.screenshotConvertBtn.addEventListener('click', convertScreenshot);
910
+ }
911
+ if (elements.screenshotInsertBtn) {
912
+ elements.screenshotInsertBtn.addEventListener('click', insertScreenshotContent);
913
+ }
914
+
915
+ // Listen for editor selection changes
916
+ listenForSelectionChanges();
917
+ }
918
+
919
+ /**
920
+ * Handle keydown in text prompt
921
+ */
922
+ function handleTextPromptKeydown(e) {
923
+ if (e.key === 'Enter' && !e.shiftKey) {
924
+ e.preventDefault();
925
+ if (!elements.textGenerateBtn.disabled && !isGenerating) {
926
+ generateText();
927
+ }
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Handle keydown in image prompt
933
+ */
934
+ function handleImagePromptKeydown(e) {
935
+ if (e.key === 'Enter' && !e.shiftKey) {
936
+ e.preventDefault();
937
+ if (!elements.imageGenerateBtn.disabled && !isGenerating) {
938
+ generateImage();
939
+ }
940
+ }
941
+ }
942
+
943
+ /**
944
+ * Handle drag over
945
+ */
946
+ function handleDragOver(e) {
947
+ e.preventDefault();
948
+ elements.uploadZone.classList.add('dragover');
949
+ }
950
+
951
+ /**
952
+ * Handle drag leave
953
+ */
954
+ function handleDragLeave(e) {
955
+ e.preventDefault();
956
+ elements.uploadZone.classList.remove('dragover');
957
+ }
958
+
959
+ /**
960
+ * Handle drop
961
+ */
962
+ function handleDrop(e) {
963
+ e.preventDefault();
964
+ elements.uploadZone.classList.remove('dragover');
965
+
966
+ const file = e.dataTransfer.files[0];
967
+ if (file && file.type.startsWith('image/')) {
968
+ processScreenshotFile(file);
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Switch between AI tabs
974
+ */
975
+ function switchTab(tabName) {
976
+ if (!aiStatus?.configured) return;
977
+
978
+ const featureEnabled = {
979
+ text: aiStatus.text_enabled,
980
+ image: aiStatus.image_enabled,
981
+ screenshot: aiStatus.screenshot_enabled
982
+ };
983
+
984
+ if (!featureEnabled[tabName]) {
985
+ showToast('This feature is disabled in settings', 'warning');
986
+ return;
987
+ }
988
+
989
+ currentTab = tabName;
990
+
991
+ elements.tabs.forEach(tab => {
992
+ tab.classList.toggle('active', tab.dataset.tab === tabName);
993
+ });
994
+
995
+ elements.tabPanels.forEach(panel => {
996
+ const isActive = panel.dataset.tab === tabName;
997
+ panel.classList.toggle('active', isActive);
998
+ panel.style.display = isActive ? 'flex' : 'none';
999
+ });
1000
+ }
1001
+
1002
+ /**
1003
+ * Switch between page/element mode
1004
+ */
1005
+ function switchMode(mode) {
1006
+ currentMode = mode;
1007
+
1008
+ elements.modeButtons.forEach(btn => {
1009
+ btn.classList.toggle('active', btn.dataset.mode === mode);
1010
+ });
1011
+
1012
+ // Update slider position
1013
+ if (elements.modeSlider) {
1014
+ elements.modeSlider.style.transform = mode === 'element' ? 'translateX(100%)' : 'translateX(0)';
1015
+ }
1016
+
1017
+ if (elements.elementInfo) {
1018
+ elements.elementInfo.style.display = mode === 'element' ? 'flex' : 'none';
1019
+ }
1020
+
1021
+ if (mode === 'element') {
1022
+ updateSelectedElementDisplay();
1023
+ }
1024
+ }
1025
+
1026
+ /**
1027
+ * Update selected element display
1028
+ */
1029
+ function updateSelectedElementDisplay() {
1030
+ if (!window.ActiveCanvasEditor?.instance) return;
1031
+
1032
+ const selected = window.ActiveCanvasEditor.instance.getSelected();
1033
+
1034
+ if (selected && elements.selectedElement) {
1035
+ const tagName = selected.get('tagName') || 'div';
1036
+ const classes = selected.getClasses().slice(0, 2).join('.');
1037
+ const id = selected.getId();
1038
+
1039
+ let name = tagName.toLowerCase();
1040
+ if (id) name = `#${id}`;
1041
+ else if (classes) name = `${tagName.toLowerCase()}.${classes}`;
1042
+
1043
+ elements.selectedElement.textContent = name;
1044
+ } else if (elements.selectedElement) {
1045
+ elements.selectedElement.textContent = 'No element selected';
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Listen for GrapeJS selection changes
1051
+ */
1052
+ function listenForSelectionChanges() {
1053
+ const checkEditor = setInterval(() => {
1054
+ if (window.ActiveCanvasEditor?.instance) {
1055
+ clearInterval(checkEditor);
1056
+ window.ActiveCanvasEditor.instance.on('component:selected', updateSelectedElementDisplay);
1057
+ window.ActiveCanvasEditor.instance.on('component:deselected', updateSelectedElementDisplay);
1058
+ }
1059
+ }, 100);
1060
+ }
1061
+
1062
+ /**
1063
+ * Update text generate button state
1064
+ */
1065
+ function updateTextButtonState() {
1066
+ if (elements.textGenerateBtn) {
1067
+ elements.textGenerateBtn.disabled = !elements.textPrompt?.value.trim() || isGenerating;
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * Update image generate button state
1073
+ */
1074
+ function updateImageButtonState() {
1075
+ if (elements.imageGenerateBtn) {
1076
+ elements.imageGenerateBtn.disabled = !elements.imagePrompt?.value.trim() || isGenerating;
1077
+ }
1078
+ }
1079
+
1080
+ // ==================== Main Generation Functions ====================
1081
+
1082
+ /**
1083
+ * Generate text content using SSE streaming
1084
+ */
1085
+ async function generateText() {
1086
+ if (isGenerating || !elements.textPrompt?.value.trim()) return;
1087
+
1088
+ const prompt = elements.textPrompt.value.trim();
1089
+ const model = getSelectedModel('text');
1090
+
1091
+ // Get current element HTML if in element mode
1092
+ let currentHtml = '';
1093
+ if (currentMode === 'element' && window.ActiveCanvasEditor?.instance) {
1094
+ const selected = window.ActiveCanvasEditor.instance.getSelected();
1095
+ if (selected) {
1096
+ currentHtml = selected.toHTML();
1097
+ }
1098
+ }
1099
+
1100
+ setGenerating(true);
1101
+
1102
+ // Hide conversation empty state, show output wrapper
1103
+ if (elements.textConversation) {
1104
+ elements.textConversation.style.display = 'none';
1105
+ }
1106
+ if (elements.textOutputWrapper) {
1107
+ elements.textOutputWrapper.style.display = 'block';
1108
+ }
1109
+
1110
+ clearOutput(elements.textOutput);
1111
+ showLoadingInOutput(elements.textOutput);
1112
+
1113
+ try {
1114
+ if (isDirectMode()) {
1115
+ await generateTextDirect(prompt, model, currentHtml);
1116
+ } else {
1117
+ // Server mode (existing behavior)
1118
+ const endpoints = window.ActiveCanvasEditor.config.aiEndpoints;
1119
+ if (!endpoints?.chat) return;
1120
+
1121
+ abortController = new AbortController();
1122
+
1123
+ const response = await fetch(endpoints.chat, {
1124
+ method: 'POST',
1125
+ headers: {
1126
+ 'Content-Type': 'application/json',
1127
+ 'X-CSRF-Token': getCSRFToken()
1128
+ },
1129
+ body: JSON.stringify({
1130
+ prompt,
1131
+ model,
1132
+ mode: currentMode,
1133
+ current_html: currentHtml
1134
+ }),
1135
+ signal: abortController.signal
1136
+ });
1137
+
1138
+ if (!response.ok) {
1139
+ const error = await response.json();
1140
+ throw new Error(error.error || 'Generation failed');
1141
+ }
1142
+
1143
+ await processSSEStream(response.body, elements.textOutput);
1144
+ }
1145
+ } catch (error) {
1146
+ if (error.name !== 'AbortError') {
1147
+ console.error('AI Text Generation Error:', error);
1148
+ // Detect Anthropic CORS issues
1149
+ if (isDirectMode() && error instanceof TypeError && error.message.includes('Failed to fetch')) {
1150
+ const provider = getModelProvider(model);
1151
+ if (provider === 'anthropic') {
1152
+ showOutputError(elements.textOutput, 'Direct mode not available for Anthropic. Try server mode or check browser settings.');
1153
+ } else {
1154
+ showOutputError(elements.textOutput, error.message);
1155
+ }
1156
+ } else {
1157
+ showOutputError(elements.textOutput, error.message);
1158
+ }
1159
+ }
1160
+ } finally {
1161
+ setGenerating(false);
1162
+ abortController = null;
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Process SSE stream and update output (server mode)
1168
+ */
1169
+ async function processSSEStream(body, outputElement) {
1170
+ const reader = body.getReader();
1171
+ const decoder = new TextDecoder();
1172
+ let buffer = '';
1173
+ let fullContent = '';
1174
+
1175
+ // Clear loading state
1176
+ outputElement.innerHTML = '';
1177
+
1178
+ try {
1179
+ while (true) {
1180
+ const { done, value } = await reader.read();
1181
+ if (done) break;
1182
+
1183
+ buffer += decoder.decode(value, { stream: true });
1184
+ const lines = buffer.split('\n');
1185
+ buffer = lines.pop() || '';
1186
+
1187
+ for (const line of lines) {
1188
+ if (line.startsWith('data: ')) {
1189
+ try {
1190
+ const data = JSON.parse(line.slice(6));
1191
+
1192
+ if (data.content) {
1193
+ fullContent += data.content;
1194
+ outputElement.textContent = fullContent;
1195
+ outputElement.scrollTop = outputElement.scrollHeight;
1196
+ }
1197
+
1198
+ if (data.error) {
1199
+ showOutputError(outputElement, data.error);
1200
+ return;
1201
+ }
1202
+ } catch (e) {
1203
+ // Ignore JSON parse errors for incomplete data
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+ } catch (error) {
1209
+ throw error;
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * Generate image
1215
+ */
1216
+ async function generateImage() {
1217
+ if (isGenerating || !elements.imagePrompt?.value.trim()) return;
1218
+
1219
+ const prompt = elements.imagePrompt.value.trim();
1220
+ const model = getSelectedModel('image');
1221
+
1222
+ setGenerating(true);
1223
+
1224
+ // Hide empty state, show output wrapper
1225
+ if (elements.imageEmpty) {
1226
+ elements.imageEmpty.style.display = 'none';
1227
+ }
1228
+ if (elements.imageOutputWrapper) {
1229
+ elements.imageOutputWrapper.style.display = 'block';
1230
+ }
1231
+
1232
+ if (elements.imageOutput) {
1233
+ elements.imageOutput.innerHTML = '<div class="ai-loading-indicator"><div class="ai-loading-dots"><span></span><span></span><span></span></div><span>Generating image...</span></div>';
1234
+ }
1235
+
1236
+ try {
1237
+ if (isDirectMode()) {
1238
+ const imageUrl = await generateImageDirect(prompt, model);
1239
+ elements.imageOutput.innerHTML = `<img src="${imageUrl}" alt="AI Generated: ${escapeHtml(prompt)}" />`;
1240
+ elements.imageOutput.dataset.imageUrl = imageUrl;
1241
+ } else {
1242
+ // Server mode (existing behavior)
1243
+ const endpoints = window.ActiveCanvasEditor.config.aiEndpoints;
1244
+ if (!endpoints?.image) return;
1245
+
1246
+ abortController = new AbortController();
1247
+ const timeoutId = setTimeout(() => abortController.abort(), 180000);
1248
+
1249
+ const response = await fetch(endpoints.image, {
1250
+ method: 'POST',
1251
+ headers: {
1252
+ 'Content-Type': 'application/json',
1253
+ 'X-CSRF-Token': getCSRFToken()
1254
+ },
1255
+ body: JSON.stringify({ prompt, model }),
1256
+ signal: abortController.signal
1257
+ });
1258
+
1259
+ clearTimeout(timeoutId);
1260
+
1261
+ const data = await response.json();
1262
+
1263
+ if (!response.ok) {
1264
+ throw new Error(data.error || 'Image generation failed');
1265
+ }
1266
+
1267
+ // Display generated image
1268
+ elements.imageOutput.innerHTML = `<img src="${data.url}" alt="AI Generated: ${escapeHtml(prompt)}" />`;
1269
+ elements.imageOutput.dataset.imageUrl = data.url;
1270
+ }
1271
+ } catch (error) {
1272
+ if (error.name === 'AbortError') {
1273
+ elements.imageOutput.innerHTML = '<div class="ai-error">Request timed out. Please try again.</div>';
1274
+ } else {
1275
+ console.error('AI Image Generation Error:', error);
1276
+ elements.imageOutput.innerHTML = `<div class="ai-error">${escapeHtml(error.message)}</div>`;
1277
+ }
1278
+ } finally {
1279
+ setGenerating(false);
1280
+ abortController = null;
1281
+ }
1282
+ }
1283
+
1284
+ /**
1285
+ * Handle screenshot file upload
1286
+ */
1287
+ function handleScreenshotUpload(event) {
1288
+ const file = event.target.files?.[0];
1289
+ if (file) {
1290
+ processScreenshotFile(file);
1291
+ }
1292
+ }
1293
+
1294
+ /**
1295
+ * Process screenshot file
1296
+ */
1297
+ function processScreenshotFile(file) {
1298
+ if (!file.type.startsWith('image/')) {
1299
+ showToast('Please upload an image file', 'error');
1300
+ return;
1301
+ }
1302
+
1303
+ const reader = new FileReader();
1304
+ reader.onload = (e) => {
1305
+ const dataUrl = e.target.result;
1306
+
1307
+ if (elements.screenshotPreview) {
1308
+ elements.screenshotPreview.innerHTML = `<img src="${dataUrl}" alt="Screenshot preview" />`;
1309
+ elements.screenshotPreview.dataset.imageData = dataUrl;
1310
+ }
1311
+
1312
+ if (elements.uploadZone) {
1313
+ elements.uploadZone.classList.add('has-preview');
1314
+ }
1315
+
1316
+ if (elements.screenshotConvertBtn) {
1317
+ elements.screenshotConvertBtn.disabled = false;
1318
+ }
1319
+ };
1320
+ reader.readAsDataURL(file);
1321
+ }
1322
+
1323
+ /**
1324
+ * Convert screenshot to code
1325
+ */
1326
+ async function convertScreenshot() {
1327
+ if (isGenerating) return;
1328
+
1329
+ const imageData = elements.screenshotPreview?.dataset.imageData;
1330
+ if (!imageData) {
1331
+ showToast('Please upload a screenshot first', 'error');
1332
+ return;
1333
+ }
1334
+
1335
+ const additionalPrompt = elements.screenshotPrompt?.value.trim();
1336
+ const model = getSelectedModel('screenshot');
1337
+
1338
+ setGenerating(true);
1339
+
1340
+ if (elements.screenshotOutputWrapper) {
1341
+ elements.screenshotOutputWrapper.style.display = 'block';
1342
+ }
1343
+
1344
+ if (elements.screenshotOutput) {
1345
+ elements.screenshotOutput.innerHTML = '<div class="ai-loading-indicator"><div class="ai-loading-dots"><span></span><span></span><span></span></div><span>Converting screenshot...</span></div>';
1346
+ }
1347
+
1348
+ try {
1349
+ if (isDirectMode()) {
1350
+ await convertScreenshotDirect(imageData, model, additionalPrompt);
1351
+ } else {
1352
+ // Server mode (existing behavior)
1353
+ const endpoints = window.ActiveCanvasEditor.config.aiEndpoints;
1354
+ if (!endpoints?.screenshot) return;
1355
+
1356
+ abortController = new AbortController();
1357
+ const timeoutId = setTimeout(() => abortController.abort(), 180000);
1358
+
1359
+ const response = await fetch(endpoints.screenshot, {
1360
+ method: 'POST',
1361
+ headers: {
1362
+ 'Content-Type': 'application/json',
1363
+ 'X-CSRF-Token': getCSRFToken()
1364
+ },
1365
+ body: JSON.stringify({
1366
+ screenshot: imageData,
1367
+ model: model,
1368
+ additional_prompt: additionalPrompt
1369
+ }),
1370
+ signal: abortController.signal
1371
+ });
1372
+
1373
+ clearTimeout(timeoutId);
1374
+
1375
+ const data = await response.json();
1376
+
1377
+ if (!response.ok) {
1378
+ throw new Error(data.error || 'Conversion failed');
1379
+ }
1380
+
1381
+ elements.screenshotOutput.textContent = data.html;
1382
+ }
1383
+ } catch (error) {
1384
+ if (error.name === 'AbortError') {
1385
+ elements.screenshotOutput.innerHTML = '<div class="ai-error">Request timed out. Please try again.</div>';
1386
+ } else {
1387
+ console.error('AI Screenshot Conversion Error:', error);
1388
+ // Detect Anthropic CORS issues
1389
+ if (isDirectMode() && error instanceof TypeError && error.message.includes('Failed to fetch')) {
1390
+ const provider = getModelProvider(model);
1391
+ if (provider === 'anthropic') {
1392
+ showOutputError(elements.screenshotOutput, 'Direct mode not available for Anthropic. Try server mode or check browser settings.');
1393
+ } else {
1394
+ elements.screenshotOutput.innerHTML = `<div class="ai-error">${escapeHtml(error.message)}</div>`;
1395
+ }
1396
+ } else {
1397
+ elements.screenshotOutput.innerHTML = `<div class="ai-error">${escapeHtml(error.message)}</div>`;
1398
+ }
1399
+ }
1400
+ } finally {
1401
+ setGenerating(false);
1402
+ abortController = null;
1403
+ }
1404
+ }
1405
+
1406
+ // ==================== Insert & Utility Functions ====================
1407
+
1408
+ /**
1409
+ * Insert text content into editor
1410
+ */
1411
+ function insertTextContent() {
1412
+ const html = elements.textOutput?.textContent;
1413
+ if (html) {
1414
+ insertHtmlToEditor(html, elements.textInsertBtn);
1415
+ }
1416
+ }
1417
+
1418
+ /**
1419
+ * Insert image content into editor
1420
+ */
1421
+ function insertImageContent() {
1422
+ const imageUrl = elements.imageOutput?.dataset.imageUrl;
1423
+ if (imageUrl) {
1424
+ const html = `<img src="${imageUrl}" alt="AI Generated Image" style="max-width: 100%; height: auto;" />`;
1425
+ insertHtmlToEditor(html, elements.imageInsertBtn);
1426
+ }
1427
+ }
1428
+
1429
+ /**
1430
+ * Insert screenshot content into editor
1431
+ */
1432
+ function insertScreenshotContent() {
1433
+ const html = elements.screenshotOutput?.textContent;
1434
+ if (html) {
1435
+ insertHtmlToEditor(html, elements.screenshotInsertBtn);
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * Copy output to clipboard
1441
+ */
1442
+ async function copyToClipboard() {
1443
+ const content = elements.textOutput?.textContent;
1444
+ if (!content) return;
1445
+
1446
+ try {
1447
+ await navigator.clipboard.writeText(content);
1448
+ showToast('Copied to clipboard', 'success');
1449
+ } catch (err) {
1450
+ showToast('Failed to copy', 'error');
1451
+ }
1452
+ }
1453
+
1454
+ /**
1455
+ * Insert HTML into GrapeJS editor
1456
+ */
1457
+ function insertHtmlToEditor(html, insertButton) {
1458
+ const editor = window.ActiveCanvasEditor?.instance;
1459
+ if (!editor) {
1460
+ showToast('Editor not ready', 'error');
1461
+ return;
1462
+ }
1463
+
1464
+ try {
1465
+ if (currentMode === 'element') {
1466
+ const selected = editor.getSelected();
1467
+ if (selected) {
1468
+ selected.components(html);
1469
+ selected.components().forEach(c => stripAutoStyles(editor, c));
1470
+ showToast('Content updated', 'success');
1471
+ } else {
1472
+ const wrapper = editor.getWrapper();
1473
+ const added = wrapper.append(html);
1474
+ added.forEach(c => stripAutoStyles(editor, c));
1475
+ showToast('Content added to page', 'success');
1476
+ }
1477
+ } else {
1478
+ const wrapper = editor.getWrapper();
1479
+ const added = wrapper.append(html);
1480
+ added.forEach(c => stripAutoStyles(editor, c));
1481
+ showToast('Content added to page', 'success');
1482
+ }
1483
+
1484
+ // Apply cooldown to prevent double-insertion
1485
+ if (insertButton) {
1486
+ startInsertCooldown(insertButton);
1487
+ }
1488
+ } catch (error) {
1489
+ console.error('Insert error:', error);
1490
+ showToast('Failed to insert content', 'error');
1491
+ }
1492
+ }
1493
+
1494
+ /**
1495
+ * Remove auto-generated CSS rules that GrapeJS adds for component-type defaults
1496
+ * (e.g. #isx3 { min-height: 100px; padding: 2rem; } on sections)
1497
+ */
1498
+ function stripAutoStyles(editor, component) {
1499
+ const id = component.getId();
1500
+ if (id) {
1501
+ const rule = editor.Css.getRule(`#${id}`);
1502
+ if (rule) {
1503
+ editor.Css.remove(rule);
1504
+ }
1505
+ }
1506
+ component.components().forEach(c => stripAutoStyles(editor, c));
1507
+ }
1508
+
1509
+ /**
1510
+ * Start cooldown on insert button to prevent accidental double-insertion
1511
+ */
1512
+ function startInsertCooldown(button) {
1513
+ if (!button) return;
1514
+
1515
+ const originalText = button.innerHTML;
1516
+ const cooldownSeconds = 20;
1517
+ let remaining = cooldownSeconds;
1518
+
1519
+ button.disabled = true;
1520
+ button.classList.add('cooldown');
1521
+ button.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"><polyline points="20 6 9 17 4 12"/></svg> Inserted (${remaining}s)`;
1522
+
1523
+ const interval = setInterval(() => {
1524
+ remaining--;
1525
+ if (remaining > 0) {
1526
+ button.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"><polyline points="20 6 9 17 4 12"/></svg> Inserted (${remaining}s)`;
1527
+ } else {
1528
+ clearInterval(interval);
1529
+ button.disabled = false;
1530
+ button.classList.remove('cooldown');
1531
+ button.innerHTML = originalText;
1532
+ }
1533
+ }, 1000);
1534
+ }
1535
+
1536
+ /**
1537
+ * Utility functions
1538
+ */
1539
+ function setGenerating(state) {
1540
+ isGenerating = state;
1541
+ updateTextButtonState();
1542
+ updateImageButtonState();
1543
+
1544
+ // Update send buttons
1545
+ [elements.textGenerateBtn, elements.imageGenerateBtn, elements.screenshotConvertBtn].forEach(btn => {
1546
+ if (btn) {
1547
+ btn.disabled = state || !getPromptValue(btn);
1548
+ btn.classList.toggle('loading', state);
1549
+ }
1550
+ });
1551
+ }
1552
+
1553
+ function getPromptValue(btn) {
1554
+ if (btn === elements.textGenerateBtn) return elements.textPrompt?.value.trim();
1555
+ if (btn === elements.imageGenerateBtn) return elements.imagePrompt?.value.trim();
1556
+ if (btn === elements.screenshotConvertBtn) return elements.screenshotPreview?.dataset.imageData;
1557
+ return true;
1558
+ }
1559
+
1560
+ function clearOutput(element) {
1561
+ if (element) {
1562
+ element.innerHTML = '';
1563
+ }
1564
+ }
1565
+
1566
+ function showLoadingInOutput(element) {
1567
+ if (element) {
1568
+ element.innerHTML = '<div class="ai-loading-indicator"><div class="ai-loading-dots"><span></span><span></span><span></span></div><span>Generating...</span></div>';
1569
+ }
1570
+ }
1571
+
1572
+ function showOutputError(element, message) {
1573
+ if (element) {
1574
+ element.innerHTML = `<div class="ai-error">${escapeHtml(message)}</div>`;
1575
+ }
1576
+ }
1577
+
1578
+ function getCSRFToken() {
1579
+ return document.querySelector('meta[name="csrf-token"]')?.content || '';
1580
+ }
1581
+
1582
+ function escapeHtml(text) {
1583
+ const div = document.createElement('div');
1584
+ div.textContent = text;
1585
+ return div.innerHTML;
1586
+ }
1587
+
1588
+ function showToast(message, type) {
1589
+ if (window.ActiveCanvasEditor?.showToast) {
1590
+ window.ActiveCanvasEditor.showToast(message, type);
1591
+ }
1592
+ }
1593
+
1594
+ // Export for global access
1595
+ window.ActiveCanvasAiPanel = {
1596
+ init,
1597
+ checkAiStatus,
1598
+ loadAvailableModels
1599
+ };
1600
+
1601
+ // Initialize when DOM is ready
1602
+ if (document.readyState === 'loading') {
1603
+ document.addEventListener('DOMContentLoaded', init);
1604
+ } else {
1605
+ init();
1606
+ }
1607
+ })();