Files

1 line
15 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "behisecc/chrome-suite", "files": {"devtools/panel/js/ai.js": {"a8dc12aa696e75b640a956ee3acb82b5dba1cad4": {"t": 1785768939.1577525, "n": 14644, "h": "2d16c5e6ff5f6444", "c": "/**\n * Chrome Suite - AI Integration (OpenRouter)\n */\n\nconst AI = {\n messagesContainer: null,\n inputEl: null,\n sendBtn: null,\n panelEl: null,\n closeBtn: null,\n aiBtn: null,\n isLoading: false,\n\n // Headers to completely skip (not sent to AI at all)\n SKIP_HEADERS: new Set([\n 'accept',\n 'accept-encoding',\n 'accept-language',\n 'cache-control',\n 'connection',\n 'content-length',\n 'dnt',\n 'pragma',\n 'priority',\n 'sec-ch-ua',\n 'sec-ch-ua-mobile',\n 'sec-ch-ua-platform',\n 'sec-fetch-dest',\n 'sec-fetch-mode',\n 'sec-fetch-site',\n 'sec-fetch-user',\n 'sec-gpc',\n 'upgrade-insecure-requests',\n 'user-agent',\n 'x-requested-with',\n 'te',\n 'via',\n 'warning'\n ]),\n\n // Headers to redact (send header name but replace value with \"Pholder\")\n REDACT_HEADERS: new Set([\n 'authorization',\n 'cookie',\n 'x-csrf-token'\n ]),\n\n // Toggle states for context inclusion (default: both ON)\n includeRequest: true,\n includeResponse: true,\n\n /**\n * Initialize AI functionality\n */\n init() {\n this.messagesContainer = document.getElementById('aiMessages');\n this.inputEl = document.getElementById('aiInput');\n this.sendBtn = document.getElementById('aiSend');\n this.panelEl = document.getElementById('aiPanel');\n this.closeBtn = document.getElementById('closeAiPanel');\n this.aiBtn = document.getElementById('aiBtn');\n this.reqToggle = document.getElementById('aiToggleRequest');\n this.resToggle = document.getElementById('aiToggleResponse');\n\n if (!this.messagesContainer || !this.inputEl || !this.sendBtn || !this.panelEl || !this.closeBtn || !this.aiBtn) {\n // If AI elements are missing, do not initialize to avoid runtime errors\n return;\n }\n\n // Initialize toggle states\n this.initToggles();\n\n // Toggle AI panel\n this.aiBtn.addEventListener('click', () => {\n this.panelEl.classList.toggle('visible');\n });\n\n // Close AI panel\n this.closeBtn.addEventListener('click', () => {\n this.panelEl.classList.remove('visible');\n });\n\n // Send message\n this.sendBtn.addEventListener('click', () => {\n if (this.isLoading) return;\n const message = this.inputEl.value.trim();\n if (message) {\n this.inputEl.value = '';\n this.sendMessage(message);\n }\n });\n\n // Enter to send\n this.inputEl.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n if (!this.isLoading) {\n this.sendBtn.click();\n }\n }\n });\n },\n\n /**\n * Set loading state\n */\n setLoading(loading) {\n this.isLoading = loading;\n if (!this.sendBtn || !this.inputEl) return;\n\n this.sendBtn.disabled = loading;\n this.inputEl.disabled = loading;\n \n if (loading) {\n this.sendBtn.classList.add('loading');\n this.sendBtn.innerHTML = `\n <svg class=\"spinner\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"12\" cy=\"12\" r=\"10\" stroke-dasharray=\"32\" stroke-dashoffset=\"32\"/>\n </svg>\n `;\n } else {\n this.sendBtn.classList.remove('loading');\n // Default \"send\" icon – you can replace this SVG with your ⟹ icon\n this.sendBtn.innerHTML = `\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z\"/>\n </svg>\n `;\n }\n },\n\n /**\n * Initialize toggle buttons for request/response inclusion\n */\n initToggles() {\n if (this.reqToggle) {\n this.reqToggle.classList.add('active');\n this.reqToggle.addEventListener('click', () => {\n this.includeRequest = !this.includeRequest;\n this.reqToggle.classList.toggle('active', this.includeRequest);\n });\n }\n if (this.resToggle) {\n this.resToggle.classList.add('active');\n this.resToggle.addEventListener('click', () => {\n this.includeResponse = !this.includeResponse;\n this.resToggle.classList.toggle('active', this.includeResponse);\n });\n }\n },\n\n /**\n * Filter headers for AI context - skip noise, redact sensitive\n * Format: no space after colon\n */\n filterHeadersForAI(headersText) {\n if (!headersText) return '';\n \n const lines = headersText.split('\\n');\n const filtered = [];\n \n for (const line of lines) {\n const colonIndex = line.indexOf(':');\n if (colonIndex === -1) {\n // Not a header line (could be request line or empty)\n filtered.push(line);\n continue;\n }\n \n const headerName = line.substring(0, colonIndex).trim().toLowerCase();\n \n // Skip headers completely\n if (this.SKIP_HEADERS.has(headerName)) {\n continue;\n }\n \n // Redact sensitive headers\n if (this.REDACT_HEADERS.has(headerName)) {\n filtered.push(`${line.substring(0, colonIndex).trim()}:Pholder`);\n continue;\n }\n \n // Keep header but format without space after colon\n const headerValue = line.substring(colonIndex + 1).trim();\n filtered.push(`${line.substring(0, colonIndex).trim()}:${headerValue}`);\n }\n \n return filtered.join('\\n');\n },\n\n /**\n * Minify JSON body for AI context\n */\n minifyBody(body) {\n if (!body || !body.trim()) return body;\n \n const trimmed = body.trim();\n \n // Try to detect and minify JSON\n if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||\n (trimmed.startsWith('[') && trimmed.endsWith(']'))) {\n try {\n const parsed = JSON.parse(trimmed);\n return JSON.stringify(parsed);\n } catch (e) {\n // Not valid JSON, return as-is\n return body;\n }\n }\n \n return body;\n },\n\n /**\n * Process raw HTTP content for AI - filter headers and minify body\n */\n processForAI(rawContent) {\n if (!rawContent) return '';\n \n // Split into headers and body at double newline\n const parts = rawContent.split(/\\r?\\n\\r?\\n/);\n if (parts.length < 2) {\n // No body, just headers\n return this.filterHeadersForAI(rawContent);\n }\n \n const headers = this.filterHeadersForAI(parts[0]);\n const body = this.minifyBody(parts.slice(1).join('\\n\\n'));\n \n return headers + '\\n\\n' + body;\n },\n\n /**\n * Send message to AI\n */\n async sendMessage(userMessage) {\n const settings = await Settings.load();\n \n if (!settings.apiKey) {\n this.addMessage('error', 'OpenRouter API key not configured. Please set it in Settings.');\n return;\n }\n\n // Add user message to chat\n this.addMessage('user', userMessage);\n \n // Show loading state (spinner + \"Waiting for AI response...\")\n this.setLoading(true);\n const loadingId = this.addMessage('loading', '');\n\n // Get current request/response context\n const context = this.getContext();\n \n // Build system instruction text\n const systemInstruction = settings.systemPrompt + `\n\nCurrent Request:\n${context.request || 'No request selected'}\n\nCurrent Response:\n${context.response || 'No response available'}`;\n\n // Prepend system instruction to user message for models that don't support system role\n const combinedUserMessage = `System instruction: \"${systemInstruction}\"\n\nUser question: ${userMessage}`;\n\n let timeoutId;\n\n try {\n const controller = new AbortController();\n timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout\n \n const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${settings.apiKey}`,\n 'HTTP-Referer': 'chrome-extension://chrome-suite',\n 'X-Title': 'Chrome Suite'\n },\n body: JSON.stringify({\n model: settings.modelName,\n messages: [\n { role: 'user', content: combinedUserMessage }\n ],\n max_tokens: 1024\n }),\n signal: controller.signal\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n let errorMsg = '';\n try {\n const errorData = await response.json();\n // Extract detailed error information\n if (errorData.error) {\n const err = errorData.error;\n const parts = [];\n \n if (err.message) parts.push(err.message);\n if (err.type) parts.push(`Type: ${err.type}`);\n if (err.code) parts.push(`Code: ${err.code}`);\n if (err.param) parts.push(`Param: ${err.param}`);\n \n // Handle nested metadata/details\n if (err.metadata) {\n if (err.metadata.raw) parts.push(`Details: ${err.metadata.raw}`);\n if (err.metadata.provider_name) parts.push(`Provider: ${err.metadata.provider_name}`);\n }\n \n errorMsg = parts.length > 0 ? parts.join('\\n') : JSON.stringify(err, null, 2);\n } else {\n // Fallback: show entire response body\n errorMsg = JSON.stringify(errorData, null, 2);\n }\n } catch (e) {\n // Could not parse JSON, use status text\n errorMsg = response.statusText || 'Unknown error';\n }\n \n this.addMessage('error', `HTTP ${response.status}\\n${errorMsg}`);\n return;\n }\n\n const data = await response.json();\n \n if (data.error) {\n // Handle error in \"successful\" response\n const err = data.error;\n let errorMsg = '';\n \n if (typeof err === 'string') {\n errorMsg = err;\n } else {\n const parts = [];\n if (err.message) parts.push(err.message);\n if (err.type) parts.push(`Type: ${err.type}`);\n if (err.code) parts.push(`Code: ${err.code}`);\n errorMsg = parts.length > 0 ? parts.join('\\n') : JSON.stringify(err, null, 2);\n }\n \n this.addMessage('error', errorMsg);\n return;\n }\n\n const aiResponse = data.choices?.[0]?.message?.content || 'No response received';\n this.addMessage('assistant', aiResponse);\n\n } catch (e) {\n if (timeoutId) clearTimeout(timeoutId);\n \n if (e.name === 'AbortError') {\n this.addMessage('error', 'Request timed out after 60 seconds. Please try again.');\n } else if (e.message && (e.message.includes('Failed to fetch') || e.message.includes('NetworkError'))) {\n this.addMessage('error', 'Network error: Unable to reach OpenRouter API. Check your internet connection.');\n } else {\n this.addMessage('error', `Connection error: ${e.message || e.toString()}`);\n }\n } finally {\n // Only now remove the loading message and spinner,\n // so the spinner persists until we have a final result (success or error).\n this.removeMessage(loadingId);\n this.setLoading(false);\n }\n },\n\n /**\n * Render markdown to HTML\n */\n renderMarkdown(text) {\n // Simple markdown renderer\n let html = Utils.escapeHtml(text);\n \n // Code blocks (```)\n html = html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (match, lang, code) => {\n return `<pre class=\"md-code-block\"><code class=\"language-${lang}\">${code.trim()}</code></pre>`;\n });\n \n // Inline code (`)\n html = html.replace(/`([^`]+)`/g, '<code class=\"md-inline-code\">$1</code>');\n \n // Bold (**)\n html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n \n // Italic (*)\n html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');\n \n // Headers\n html = html.replace(/^### (.+)$/gm, '<h4 class=\"md-h4\">$1</h4>');\n html = html.replace(/^## (.+)$/gm, '<h3 class=\"md-h3\">$1</h3>');\n html = html.replace(/^# (.+)$/gm, '<h2 class=\"md-h2\">$1</h2>');\n \n // Unordered lists\n html = html.replace(/^- (.+)$/gm, '<li class=\"md-li\">$1</li>');\n html = html.replace(/(<li class=\"md-li\">.*<\\/li>\\n?)+/g, '<ul class=\"md-ul\">$&</ul>');\n \n // Ordered lists\n html = html.replace(/^\\d+\\. (.+)$/gm, '<li class=\"md-li\">$1</li>');\n \n // Line breaks\n html = html.replace(/\\n/g, '<br>');\n \n // Clean up multiple <br> in code blocks\n html = html.replace(/<pre class=\"md-code-block\"><code([^>]*)>([\\s\\S]*?)<\\/code><\\/pre>/g, \n (match, attrs, code) => {\n return `<pre class=\"md-code-block\"><code${attrs}>${code.replace(/<br>/g, '\\n')}</code></pre>`;\n });\n \n return html;\n },\n\n /**\n * Add message to chat\n */\n addMessage(type, content) {\n if (!this.messagesContainer) return;\n\n const id = Utils.generateId();\n const messageEl = document.createElement('div');\n messageEl.className = `ai-message ${type}`;\n messageEl.id = `ai-msg-${id}`;\n \n if (type === 'loading') {\n messageEl.innerHTML = `\n <div class=\"ai-loading\">\n <svg class=\"spinner\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n </svg>\n <span>Waiting for AI response...</span>\n </div>\n `;\n } else if (type === 'assistant') {\n messageEl.innerHTML = this.renderMarkdown(content);\n } else if (type === 'error') {\n messageEl.innerHTML = `\n <div class=\"ai-error\">\n <svg class=\"ai-error-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"12\" cy=\"12\" r=\"10\"/>\n <line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"/>\n <line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"/>\n </svg>\n <span>${Utils.escapeHtml(content)}</span>\n </div>\n `;\n } else {\n messageEl.textContent = content;\n }\n \n this.messagesContainer.appendChild(messageEl);\n this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;\n \n return id;\n },\n\n /**\n * Remove message by ID\n */\n removeMessage(id) {\n const el = document.getElementById(`ai-msg-${id}`);\n if (el) el.remove();\n },\n\n /**\n * Get current request/response context for AI (processed)\n */\n getContext() {\n let request = '';\n let response = '';\n\n if (this.includeRequest) {\n const rawRequest = window.EditorPane?.getRawContent() || '';\n request = this.processForAI(rawRequest);\n }\n\n if (this.includeResponse) {\n const rawResponse = window.ResponsePane?.getRawContent() || '';\n response = this.processForAI(rawResponse);\n }\n\n return { request, response };\n },\n\n /**\n * Clear all messages\n */\n clear() {\n if (this.messagesContainer) {\n this.messagesContainer.innerHTML = '';\n }\n }\n};\n\n// Make available globally\nwindow.AI = AI;\n"}}}}