1 line
15 KiB
JSON
1 line
15 KiB
JSON
{"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"}}}} |