Repository navigation
Expand file tree
/
Copy pathpanel.js
More file actions
697 lines (593 loc) · 21.1 KB
/
Copy pathpanel.js
File metadata and controls
697 lines (593 loc) · 21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
// DevTools panel script
// Handles UI interactions and communication with AI providers (Claude, Gemini, Local)
const messagesContainer = document.getElementById('messages');
const chatContainer = document.getElementById('chat-container');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const settingsBtn = document.getElementById('settings-btn');
const settingsPanel = document.getElementById('settings-panel');
const closeSettingsBtn = document.getElementById('close-settings');
const apiKeyInput = document.getElementById('api-key');
const saveSettingsBtn = document.getElementById('save-settings');
const statusElement = document.getElementById('status');
const aiProviderSelect = document.getElementById('ai-provider');
const apiKeyHelp = document.getElementById('api-key-help');
const settingsTitle = document.getElementById('settings-title');
const apiKeyLabel = document.querySelector('#settings-panel label');
let claudeApiKey = '';
let geminiApiKey = '';
let aiProvider = 'claude';
let conversationHistory = [];
let backgroundPort = null;
let tabId = chrome.devtools.inspectedWindow.tabId;
let currentCode = { html: '', css: '', js: '' };
let isPortConnected = false;
let agent = null;
// Provider configuration
const PROVIDER_NAMES = {
'claude': 'Claude',
'gemini': 'Gemini',
'local': 'Local'
};
// Helper to get current API key
const getApiKey = () => {
const apiKeys = { 'gemini': geminiApiKey, 'claude': claudeApiKey };
return apiKeys[aiProvider];
};
// Initialize connection to background script
function initConnection() {
// Don't create a new connection if we already have one
if (isPortConnected && backgroundPort) {
return;
}
backgroundPort = chrome.runtime.connect({ name: 'devtools-panel' });
isPortConnected = true;
if (agent) agent.setBackgroundPort(backgroundPort);
backgroundPort.postMessage({
type: 'INIT',
tabId: tabId
});
backgroundPort.onMessage.addListener((message) => {
if (message.type === 'CONTENT_READY') {
// Clear chat on page reload
messagesContainer.innerHTML = '';
conversationHistory = [];
updateStatus(true);
refreshCode();
}
if (message.type === 'CODE_DATA') {
currentCode = message.data.code;
updateStatus(true);
}
if (message.type === 'LOCAL_AI_STATUS') {
if (message.available) {
apiKeyHelp.innerHTML = 'Uses built-in Chrome AI.';
} else {
apiKeyHelp.innerHTML = 'Uses built-in Chrome AI.<br><br>Enable these flags:<br><code>chrome://flags/#prompt-api-for-gemini-nano</code><br><code>chrome://flags/#optimization-guide-on-device-model</code>';
}
}
if (message.type === 'ERROR') {
addSystemMessage('Error: ' + message.error);
}
});
backgroundPort.onDisconnect.addListener(() => {
isPortConnected = false;
if (agent) agent.setPortConnected(false);
updateStatus(false);
setTimeout(() => {
if (!isPortConnected) {
initConnection();
}
}, 1000);
});
setTimeout(() => {
refreshCode();
}, 1000);
}
// Load saved API key
async function loadSettings() {
const result = await chrome.storage.local.get(['claudeApiKey', 'geminiApiKey', 'aiProvider']);
if (result.claudeApiKey) claudeApiKey = result.claudeApiKey;
if (result.geminiApiKey) geminiApiKey = result.geminiApiKey;
if (result.aiProvider) {
aiProvider = result.aiProvider;
aiProviderSelect.value = aiProvider;
}
updateApiKeyHelp();
const currentKey = getApiKey();
if (currentKey) {
apiKeyInput.value = currentKey;
createAgent();
} else if (aiProvider === 'local') {
// Local provider doesn't need API key
createAgent();
}
}
// Create agent based on selected provider
function createAgent() {
if (aiProvider === 'local') {
agent = new LocalAgent();
} else if (aiProvider === 'gemini') {
agent = new GeminiAgent(geminiApiKey);
} else {
agent = new ClaudeAgent(claudeApiKey);
}
if (backgroundPort) agent.setBackgroundPort(backgroundPort);
}
// Update API key help text based on provider
function updateApiKeyHelp() {
if (aiProvider === 'local') {
settingsTitle.textContent = 'Local Settings';
apiKeyInput.style.display = 'none';
apiKeyLabel.style.display = 'none';
saveSettingsBtn.style.display = 'none';
// Set default text immediately
apiKeyHelp.innerHTML = 'Uses built-in Chrome AI.';
// Check if local AI is available
if (backgroundPort && isPortConnected) {
backgroundPort.postMessage({ type: 'CHECK_LOCAL_AI' });
} else {
apiKeyHelp.innerHTML = 'Uses built-in Chrome AI.<br><br>Enable these flags:<br><code>chrome://flags/#prompt-api-for-gemini-nano</code><br><code>chrome://flags/#optimization-guide-on-device-model</code>';
}
} else if (aiProvider === 'gemini') {
apiKeyInput.style.display = '';
apiKeyLabel.style.display = '';
saveSettingsBtn.style.display = '';
settingsTitle.textContent = 'Gemini Settings';
apiKeyHelp.innerHTML = 'Get your API key from <a href="https://aistudio.google.com/apikey" target="_blank">Google AI Studio</a>';
} else {
apiKeyInput.style.display = '';
apiKeyLabel.style.display = '';
saveSettingsBtn.style.display = '';
settingsTitle.textContent = 'Claude Settings';
apiKeyHelp.innerHTML = 'Get your API key from the <a href="https://console.anthropic.com/" target="_blank">Anthropic Console</a>';
}
}
// Save API key
async function saveSettings() {
if (aiProvider === 'local') {
createAgent();
addSystemMessage('Settings saved (Local)');
settingsPanel.classList.add('hidden');
return;
}
const newKey = apiKeyInput.value.trim();
if (newKey) {
if (aiProvider === 'gemini') {
geminiApiKey = newKey;
await chrome.storage.local.set({ geminiApiKey: geminiApiKey });
} else {
claudeApiKey = newKey;
await chrome.storage.local.set({ claudeApiKey: claudeApiKey });
}
createAgent();
addSystemMessage(`Settings saved (${PROVIDER_NAMES[aiProvider]})`);
settingsPanel.classList.add('hidden');
} else {
addSystemMessage('Please enter a valid API key');
}
}
// Update connection status
function updateStatus(connected) {
if (connected) {
statusElement.textContent = 'Connected to CodePen';
statusElement.className = 'status-connected';
} else {
statusElement.textContent = 'Not connected';
statusElement.className = 'status-disconnected';
}
}
// Request code from CodePen
function refreshCode() {
if (backgroundPort && isPortConnected) {
try {
backgroundPort.postMessage({
type: 'GET_CODE',
tabId: tabId
});
} catch (error) {
console.error('Error sending GET_CODE message:', error);
if (error.message.includes('disconnected port')) {
isPortConnected = false;
initConnection();
}
}
}
}
// Add message to chat
function addMessage(role, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `message message-${role}`;
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
// Format content with collapsible code blocks
contentDiv.innerHTML = formatMessageContent(content);
messageDiv.appendChild(contentDiv);
messagesContainer.appendChild(messageDiv);
// Scroll to bottom after DOM updates
scrollToBottom();
return messageDiv;
}
// Scroll chat to bottom
function scrollToBottom() {
// Use setTimeout to ensure DOM has updated
setTimeout(() => {
chatContainer.scrollTo({
top: chatContainer.scrollHeight,
behavior: 'smooth'
});
}, 0);
}
// Format SEARCH/REPLACE block as colored diff
function formatDiffBlock(blockContent, escapeHtml) {
const sections = blockContent.split('<<<SEARCH>>>').filter(s => s.trim());
let html = '';
for (const section of sections) {
if (!section.includes('<<<REPLACE>>>')) continue;
const [searchPart, replacePart] = section.split('<<<REPLACE>>>');
const searchText = searchPart.trim();
const replaceText = replacePart.split('<<<')[0].trim();
// Compute character-level diff
const diff = Diff.diffChars(searchText, replaceText);
let removeHtml = '';
let addHtml = '';
for (const part of diff) {
const escaped = escapeHtml(part.value);
if (part.removed) {
removeHtml += '<span class="diff-highlight-remove">' + escaped + '</span>';
} else if (part.added) {
addHtml += '<span class="diff-highlight-add">' + escaped + '</span>';
} else {
removeHtml += escaped;
addHtml += escaped;
}
}
html += '<div class="diff-block">';
html += '<div class="diff-remove">' + removeHtml + '</div>';
html += '<div class="diff-add">' + addHtml + '</div>';
html += '</div>';
}
return html;
}
// Format message content with collapsible code blocks
function formatMessageContent(content) {
// Escape HTML to prevent XSS
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// Pattern to match code blocks: [UPDATE_XXX]...[/UPDATE_XXX]
const codeBlockPattern = /\[UPDATE_(HTML|CSS|JS)\]([\s\S]*?)\[\/UPDATE_\1\]/g;
let lastIndex = 0;
let result = '';
let match;
while ((match = codeBlockPattern.exec(content)) !== null) {
// Add text before the code block (render as markdown)
if (match.index > lastIndex) {
const textBefore = content.substring(lastIndex, match.index);
result += marked.parse(textBefore);
}
const language = match[1].toLowerCase();
const blockContent = match[2].trim();
// Check if this is a SEARCH/REPLACE block or complete code
if (blockContent.includes('<<<SEARCH>>>') && blockContent.includes('<<<REPLACE>>>')) {
// Format as SEARCH/REPLACE diff with colored view
const diffHtml = formatDiffBlock(blockContent, escapeHtml);
result += `<details open>
<summary>${language.toUpperCase()} Changes</summary>
<div class="diff-view">${diffHtml}</div>
</details>`;
} else {
// Format as complete code
result += `<details open>
<summary>${language.toUpperCase()} Code</summary>
<pre><code>${escapeHtml(blockContent)}</code></pre>
</details>`;
}
lastIndex = match.index + match[0].length;
}
// Add remaining text after last code block (render as markdown)
if (lastIndex < content.length) {
const textAfter = content.substring(lastIndex);
result += marked.parse(textAfter);
}
return result;
}
// Add system message
function addSystemMessage(content) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message message-system';
messageDiv.textContent = content;
messagesContainer.appendChild(messageDiv);
scrollToBottom();
}
// Add thinking indicator
function addThinkingMessage() {
const messageDiv = document.createElement('div');
messageDiv.className = 'message message-assistant';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content thinking';
contentDiv.innerHTML = '<span class="thinking-dots"><span>.</span><span>.</span><span>.</span></span>';
messageDiv.appendChild(contentDiv);
messagesContainer.appendChild(messageDiv);
scrollToBottom();
return messageDiv;
}
// Send message to AI provider
async function sendMessage() {
const message = userInput.value.trim();
if (!message) return;
if (!agent) {
const providerName = PROVIDER_NAMES[aiProvider];
const message = aiProvider === 'local'
? 'Local AI not available. Please check Chrome flags.'
: `Please set your ${providerName} API key in settings`;
addSystemMessage(message);
settingsPanel.classList.remove('hidden');
return;
}
// Add user message to chat
addMessage('user', message);
userInput.value = '';
sendBtn.disabled = true;
// Add thinking indicator
const thinkingMessage = addThinkingMessage();
// Refresh code before sending
await new Promise(resolve => {
if (backgroundPort && isPortConnected) {
try {
backgroundPort.postMessage({
type: 'GET_CODE',
tabId: tabId
});
// Wait a bit for the response
setTimeout(resolve, 500);
} catch (error) {
console.error('Error sending GET_CODE message:', error);
if (error.message.includes('disconnected port')) {
isPortConnected = false;
initConnection();
}
resolve();
}
} else {
resolve();
}
});
// Build system prompt with current code
const systemPrompt = buildSystemPrompt();
// Add to conversation history
conversationHistory.push({
role: 'user',
content: message
});
try {
// Call AI provider API
const response = await agent.sendMessage(systemPrompt, conversationHistory);
// Remove thinking indicator
thinkingMessage.remove();
// Add assistant response
addMessage('assistant', response);
// Add to history (strip out UPDATE blocks to avoid confusion)
const responseWithoutCode = response.replace(/\[UPDATE_(HTML|CSS|JS)\][\s\S]*?\[\/UPDATE_\1\]/g, '').trim();
conversationHistory.push({
role: 'assistant',
content: responseWithoutCode || 'Code updated.'
});
// Check if we need to update code
const errors = await processAssistantResponse(response);
// If there were search/replace errors, add them to conversation history
if (errors && errors.length > 0) {
const errorMessage = 'The following SEARCH blocks could not be found in the current code:\n\n' +
errors.join('\n\n') +
'\n\nPlease check the CURRENT CODE section and try again with the exact code that exists.';
conversationHistory.push({
role: 'user',
content: errorMessage
});
}
} catch (error) {
// Remove thinking indicator on error
thinkingMessage.remove();
addSystemMessage('Error: ' + error.message);
console.error('Error calling AI provider:', error);
} finally {
sendBtn.disabled = false;
}
}
// Build system prompt with current CodePen code
function buildSystemPrompt() {
return `You are an AI coding assistant integrated into Chrome DevTools for CodePen. You can read and modify the code in the CodePen editor.
=== CURRENT CODE IN EDITOR (always fresh, always up-to-date) ===
HTML:
\`\`\`html
${currentCode.html || '(empty)'}
\`\`\`
CSS:
\`\`\`css
${currentCode.css || '(empty)'}
\`\`\`
JavaScript:
\`\`\`javascript
${currentCode.js || '(empty)'}
\`\`\`
=== END CURRENT CODE ===
CRITICAL: The code shown above is the ACTUAL, CURRENT state of the CodePen editor RIGHT NOW. Always use this code as your reference, not code from previous messages in the conversation. This code is refreshed on every request.
When the user asks you to modify code:
1. FIRST: Look at the CURRENT CODE section above to see what's actually in the editor
2. Respond with a clear explanation of what you've done (use past tense)
3. Use special markers to indicate code changes using SEARCH/REPLACE blocks:
- [UPDATE_HTML]...[/UPDATE_HTML]
- [UPDATE_CSS]...[/UPDATE_CSS]
- [UPDATE_JS]...[/UPDATE_JS]
4. Inside the markers, use this format for each change:
<<<SEARCH>>>
exact code to find and replace (copy EXACTLY from CURRENT CODE above)
<<<REPLACE>>>
new code to replace with
Example:
User: "Change the background to blue"
You: "I've updated the CSS to change the background to blue.
[UPDATE_CSS]
<<<SEARCH>>>
background: red;
<<<REPLACE>>>
background: blue;
[/UPDATE_CSS]"
Important:
- Do not use markdown formatting in your responses. Write plain text without bold, italics, lists, or code blocks (except for the UPDATE markers above).
- ALWAYS refer to the CURRENT CODE section at the top - it's always up-to-date
- IGNORE any code from previous messages - ONLY use the CURRENT CODE section above
- SEARCH blocks must match the CURRENT CODE EXACTLY (including all whitespace and indentation)
- Copy-paste from the CURRENT CODE section to ensure exact matches
- You can have multiple SEARCH/REPLACE pairs in one UPDATE block
- Keep SEARCH blocks small and focused - just the lines you need to change
Be concise and helpful. Focus on the specific changes requested.`;
}
// Process assistant response and update CodePen if needed
async function processAssistantResponse(response) {
const updates = {
html: applySearchReplace(currentCode.html, response, 'UPDATE_HTML'),
css: applySearchReplace(currentCode.css, response, 'UPDATE_CSS'),
js: applySearchReplace(currentCode.js, response, 'UPDATE_JS')
};
const allErrors = [];
for (const [editor, result] of Object.entries(updates)) {
if (result !== null) {
if (result.errors && result.errors.length > 0) {
allErrors.push(...result.errors);
}
if (result.code) {
// Update our local copy
currentCode[editor] = result.code;
// Update CodePen with line highlighting
await updateCodePenEditor(editor, result.code, result.lines);
addSystemMessage(`Updated ${editor.toUpperCase()} editor`);
}
}
}
return allErrors.length > 0 ? allErrors : null;
}
// Apply SEARCH/REPLACE blocks to code
function applySearchReplace(currentCode, responseText, marker) {
const startMarker = `[${marker}]`;
const endMarker = `[/${marker}]`;
const startIndex = responseText.indexOf(startMarker);
const endIndex = responseText.indexOf(endMarker);
if (startIndex === -1 || endIndex === -1) {
return null;
}
const blockContent = responseText.substring(startIndex + startMarker.length, endIndex);
let newCode = currentCode || '';
// Split by <<<SEARCH>>> to find all search/replace pairs
const sections = blockContent.split('<<<SEARCH>>>').filter(s => s.trim());
let hasChanges = false;
const changedLines = new Set();
const errors = [];
for (const section of sections) {
// Check if this section has a <<<REPLACE>>> marker
if (!section.includes('<<<REPLACE>>>')) {
continue;
}
const [searchPart, replacePart] = section.split('<<<REPLACE>>>');
const searchText = searchPart.trim();
const replaceText = replacePart.split('<<<')[0].trim(); // Stop at next marker or end
const searchIndex = newCode.indexOf(searchText);
if (searchIndex !== -1) {
// Find which lines were affected
const beforeSearch = newCode.substring(0, searchIndex);
const startLine = beforeSearch.split('\n').length - 1;
const searchLines = searchText.split('\n').length;
const replaceLines = replaceText.split('\n').length;
// Mark affected lines
for (let i = 0; i < Math.max(searchLines, replaceLines); i++) {
changedLines.add(startLine + i);
}
newCode = newCode.replace(searchText, replaceText);
hasChanges = true;
} else {
const editorName = marker.replace('UPDATE_', '');
const errorMsg = `In ${editorName} editor, could not find:\n${searchText}`;
console.warn(errorMsg);
errors.push(errorMsg);
addSystemMessage(`Could not find text to replace in ${editorName}`);
}
}
// Return result with errors
if (hasChanges) {
return { code: newCode, lines: Array.from(changedLines), errors };
} else if (errors.length > 0) {
// No changes made, but there were errors
return { code: null, lines: [], errors };
} else {
return null;
}
}
// Update CodePen editor
async function updateCodePenEditor(editor, newCode, changedLines = []) {
return new Promise((resolve) => {
if (backgroundPort && isPortConnected) {
try {
backgroundPort.postMessage({
type: 'UPDATE_CODE',
tabId: tabId,
editor: editor,
code: newCode,
changedLines: changedLines
});
} catch (error) {
console.error('Error sending UPDATE_CODE message:', error);
isPortConnected = false;
if (error.message.includes('disconnected port')) {
initConnection();
}
}
}
setTimeout(resolve, 200);
});
}
// Event listeners
sendBtn.addEventListener('click', sendMessage);
userInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
settingsBtn.addEventListener('click', () => {
settingsPanel.classList.toggle('hidden');
});
closeSettingsBtn.addEventListener('click', () => {
settingsPanel.classList.add('hidden');
});
saveSettingsBtn.addEventListener('click', saveSettings);
aiProviderSelect.addEventListener('change', async () => {
aiProvider = aiProviderSelect.value;
await chrome.storage.local.set({ aiProvider: aiProvider });
updateApiKeyHelp();
if (aiProvider === 'local') {
apiKeyInput.value = '';
createAgent();
} else {
const currentKey = getApiKey();
apiKeyInput.value = currentKey;
if (currentKey) {
createAgent();
} else {
agent = null;
}
}
addSystemMessage(`Switched to ${PROVIDER_NAMES[aiProvider]}`);
});
// Initialize
loadSettings();
initConnection();
// Check connection status after a delay and show appropriate message
setTimeout(() => {
const isConnected = statusElement.classList.contains('status-connected');
if (!isConnected) {
addSystemMessage('Make sure you are on a CodePen editor page (codepen.io/pen/).');
addSystemMessage('If status shows "Not connected", check the Console for debugging info.');
}
}, 1500);