active_canvas 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +318 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/active_canvas/editor/ai_panel.js +1607 -0
- data/app/assets/javascripts/active_canvas/editor/asset_manager.js +498 -0
- data/app/assets/javascripts/active_canvas/editor/blocks.js +1083 -0
- data/app/assets/javascripts/active_canvas/editor/code_panel.js +572 -0
- data/app/assets/javascripts/active_canvas/editor/component_toolbar.js +394 -0
- data/app/assets/javascripts/active_canvas/editor/panels.js +460 -0
- data/app/assets/javascripts/active_canvas/editor/utils.js +56 -0
- data/app/assets/javascripts/active_canvas/editor.js +295 -0
- data/app/assets/stylesheets/active_canvas/application.css +15 -0
- data/app/assets/stylesheets/active_canvas/editor.css +2929 -0
- data/app/controllers/active_canvas/admin/ai_controller.rb +181 -0
- data/app/controllers/active_canvas/admin/application_controller.rb +56 -0
- data/app/controllers/active_canvas/admin/media_controller.rb +61 -0
- data/app/controllers/active_canvas/admin/page_types_controller.rb +57 -0
- data/app/controllers/active_canvas/admin/page_versions_controller.rb +23 -0
- data/app/controllers/active_canvas/admin/pages_controller.rb +133 -0
- data/app/controllers/active_canvas/admin/partials_controller.rb +88 -0
- data/app/controllers/active_canvas/admin/settings_controller.rb +256 -0
- data/app/controllers/active_canvas/application_controller.rb +20 -0
- data/app/controllers/active_canvas/pages_controller.rb +18 -0
- data/app/controllers/concerns/active_canvas/current_user.rb +12 -0
- data/app/controllers/concerns/active_canvas/rate_limitable.rb +75 -0
- data/app/controllers/concerns/active_canvas/tailwind_compilation.rb +39 -0
- data/app/helpers/active_canvas/application_helper.rb +4 -0
- data/app/jobs/active_canvas/application_job.rb +4 -0
- data/app/jobs/active_canvas/compile_tailwind_job.rb +64 -0
- data/app/mailers/active_canvas/application_mailer.rb +6 -0
- data/app/models/active_canvas/ai_model.rb +136 -0
- data/app/models/active_canvas/application_record.rb +5 -0
- data/app/models/active_canvas/media.rb +141 -0
- data/app/models/active_canvas/page.rb +85 -0
- data/app/models/active_canvas/page_type.rb +22 -0
- data/app/models/active_canvas/page_version.rb +80 -0
- data/app/models/active_canvas/partial.rb +73 -0
- data/app/models/active_canvas/setting.rb +292 -0
- data/app/services/active_canvas/ai_configuration.rb +40 -0
- data/app/services/active_canvas/ai_models.rb +128 -0
- data/app/services/active_canvas/ai_service.rb +289 -0
- data/app/services/active_canvas/content_sanitizer.rb +112 -0
- data/app/services/active_canvas/tailwind_compiler.rb +156 -0
- data/app/views/active_canvas/admin/media/index.html.erb +401 -0
- data/app/views/active_canvas/admin/media/show.html.erb +297 -0
- data/app/views/active_canvas/admin/page_types/_form.html.erb +25 -0
- data/app/views/active_canvas/admin/page_types/edit.html.erb +13 -0
- data/app/views/active_canvas/admin/page_types/index.html.erb +29 -0
- data/app/views/active_canvas/admin/page_types/new.html.erb +9 -0
- data/app/views/active_canvas/admin/page_types/show.html.erb +18 -0
- data/app/views/active_canvas/admin/page_versions/show.html.erb +469 -0
- data/app/views/active_canvas/admin/pages/_form.html.erb +62 -0
- data/app/views/active_canvas/admin/pages/content.html.erb +139 -0
- data/app/views/active_canvas/admin/pages/edit.html.erb +335 -0
- data/app/views/active_canvas/admin/pages/editor.html.erb +710 -0
- data/app/views/active_canvas/admin/pages/index.html.erb +149 -0
- data/app/views/active_canvas/admin/pages/new.html.erb +19 -0
- data/app/views/active_canvas/admin/pages/show.html.erb +258 -0
- data/app/views/active_canvas/admin/pages/versions.html.erb +333 -0
- data/app/views/active_canvas/admin/partials/edit.html.erb +182 -0
- data/app/views/active_canvas/admin/partials/editor.html.erb +703 -0
- data/app/views/active_canvas/admin/partials/index.html.erb +131 -0
- data/app/views/active_canvas/admin/settings/show.html.erb +1864 -0
- data/app/views/active_canvas/pages/no_homepage.html.erb +45 -0
- data/app/views/active_canvas/pages/show.html.erb +113 -0
- data/app/views/layouts/active_canvas/admin/application.html.erb +960 -0
- data/app/views/layouts/active_canvas/admin/editor.html.erb +826 -0
- data/app/views/layouts/active_canvas/application.html.erb +55 -0
- data/config/routes.rb +48 -0
- data/db/migrate/20260202000001_create_active_canvas_tables.rb +113 -0
- data/db/migrate/20260202000002_create_active_canvas_ai_models.rb +26 -0
- data/lib/active_canvas/configuration.rb +232 -0
- data/lib/active_canvas/engine.rb +44 -0
- data/lib/active_canvas/version.rb +3 -0
- data/lib/active_canvas.rb +26 -0
- data/lib/generators/active_canvas/install/install_generator.rb +263 -0
- data/lib/generators/active_canvas/install/templates/initializer.rb.tt +163 -0
- data/lib/tasks/active_canvas_tasks.rake +69 -0
- metadata +150 -0
|
@@ -0,0 +1,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
|
+
})();
|