Last active
January 5, 2026 17:57
-
-
Save christopherwoodall/ce8f1cbcbeba4489d7aed41c3144770e to your computer and use it in GitHub Desktop.
An AI Agent that lives in your browser.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Browser AI Agent | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2.2 | |
| // @description ReACT-based AI agent with JavaScript execution capabilities | |
| // @author You | |
| // @match *://*/* | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_xmlhttpRequest | |
| // @connect openrouter.ai | |
| // ==/UserScript== | |
| (function() { | |
| "use strict"; | |
| const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant embedded in a browser with ReACT (Reasoning and Acting) capabilities. | |
| CONTEXT: You are embedded in the user's current browser page. When users ask questions, they are usually asking about THIS PAGE they're viewing right now. Always check the current page first before looking elsewhere. | |
| CRITICAL RULES: | |
| 1. Send ONLY ONE Action per response - then STOP and wait for Observation | |
| 2. Never write "Observation:" yourself - I will provide it after executing your Action | |
| 3. After receiving an Observation, you can think and send another Action, or provide final Answer | |
| 4. Prefer local page inspection (execute_js, get_page_info) before fetching remote URLs | |
| Available tools: | |
| - get_page_info: Get current page URL, title, domain | |
| Example: Action: {"tool": "get_page_info"} | |
| - execute_js: Execute JavaScript in the current page context | |
| Example: Action: {"tool": "execute_js", "code": "document.querySelectorAll('h1').length"} | |
| - fetch_url: Fetch remote URL content (bypasses CORS) | |
| Example: Action: {"tool": "fetch_url", "url": "https://example.com"} | |
| Returns: {success, status, content, error?} | |
| Response format: | |
| Thought: [Your reasoning about what to do next] | |
| Action: [JSON tool call on a single line] | |
| Then STOP. Wait for my Observation. Do not write "Observation:" yourself. | |
| After you receive "Observation: {...}", you can either: | |
| - Send another Thought + Action if you need more info | |
| - Provide your final Answer: [response to user] | |
| Be concise. Remember: questions are usually about the current page unless stated otherwise.`; | |
| const DEFAULT_MODEL = "anthropic/claude-3.5-sonnet"; | |
| const DEFAULT_MAX_ITERATIONS = 10; | |
| class MessageManager { | |
| constructor() { | |
| this.messages = []; | |
| this.listeners = []; | |
| } | |
| addMessage(role, content, metadata = {}) { | |
| const message = { | |
| "id": Date.now() + Math.random(), | |
| "role": role, | |
| "content": content, | |
| "timestamp": Date.now(), | |
| "pinned": false, | |
| "included": true, | |
| ...metadata | |
| }; | |
| this.messages.push(message); | |
| this.notifyListeners(); | |
| return message; | |
| } | |
| removeMessage(id) { | |
| const index = this.messages.findIndex(msg => msg.id === id); | |
| if (index !== -1) { | |
| this.messages.splice(index, 1); | |
| this.notifyListeners(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| updateMessage(id, updates) { | |
| const message = this.messages.find(msg => msg.id === id); | |
| if (message) { | |
| Object.assign(message, updates); | |
| this.notifyListeners(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| toggleMessageInclusion(id) { | |
| const message = this.messages.find(msg => msg.id === id); | |
| if (message) { | |
| message.included = !message.included; | |
| this.notifyListeners(); | |
| return message.included; | |
| } | |
| return false; | |
| } | |
| toggleMessagePin(id) { | |
| const message = this.messages.find(msg => msg.id === id); | |
| if (message) { | |
| message.pinned = !message.pinned; | |
| this.notifyListeners(); | |
| return message.pinned; | |
| } | |
| return false; | |
| } | |
| getIncludedMessages() { | |
| return this.messages.filter(msg => msg.included); | |
| } | |
| getPinnedMessages() { | |
| return this.messages.filter(msg => msg.pinned); | |
| } | |
| clear() { | |
| this.messages = []; | |
| this.notifyListeners(); | |
| } | |
| onChange(callback) { | |
| this.listeners.push(callback); | |
| } | |
| notifyListeners() { | |
| this.listeners.forEach(callback => callback(this.messages)); | |
| } | |
| exportMessages() { | |
| return JSON.stringify(this.messages, null, 2); | |
| } | |
| importMessages(jsonString) { | |
| try { | |
| const imported = JSON.parse(jsonString); | |
| if (Array.isArray(imported)) { | |
| this.messages = imported; | |
| this.notifyListeners(); | |
| return true; | |
| } | |
| } catch (e) { | |
| console.error("Failed to import messages:", e); | |
| } | |
| return false; | |
| } | |
| } | |
| class BrowserAgent { | |
| constructor() { | |
| this.apiKey = GM_getValue("apiKey", ""); | |
| this.model = GM_getValue("model", DEFAULT_MODEL); | |
| this.systemPrompt = GM_getValue("systemPrompt", DEFAULT_SYSTEM_PROMPT); | |
| this.maxIterations = GM_getValue("maxIterations", DEFAULT_MAX_ITERATIONS); | |
| this.messageManager = new MessageManager(); | |
| this.isOpen = false; | |
| this.currentTab = "chat"; | |
| this.isProcessing = false; | |
| this.debugMode = false; | |
| this.editingMessageId = null; | |
| this.init(); | |
| } | |
| init() { | |
| this.createUI(); | |
| this.attachEventListeners(); | |
| this.messageManager.onChange(() => this.renderMessages()); | |
| } | |
| createUI() { | |
| const container = document.createElement("div"); | |
| container.id = "ai-agent-container"; | |
| container.innerHTML = ` | |
| <style> | |
| #ai-agent-container { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| z-index: 999999; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| } | |
| #ai-agent-toggle { | |
| width: 60px; | |
| height: 60px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| border: none; | |
| color: white; | |
| font-size: 24px; | |
| cursor: pointer; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.3); | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| #ai-agent-toggle:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 6px 16px rgba(0,0,0,0.4); | |
| } | |
| #ai-agent-toggle:active { | |
| transform: scale(0.98); | |
| } | |
| #ai-agent-window { | |
| display: none; | |
| position: fixed; | |
| bottom: 90px; | |
| right: 20px; | |
| width: 450px; | |
| height: 600px; | |
| background: #1a1a1a; | |
| border-radius: 12px; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.5); | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| #ai-agent-window.open { | |
| display: flex; | |
| } | |
| .agent-header { | |
| background: #2a2a2a; | |
| padding: 16px; | |
| display: flex; | |
| gap: 8px; | |
| border-bottom: 1px solid #3a3a3a; | |
| } | |
| .tab-btn { | |
| flex: 1; | |
| padding: 10px; | |
| background: transparent; | |
| border: none; | |
| color: #999; | |
| cursor: pointer; | |
| border-radius: 6px; | |
| transition: all 0.2s; | |
| font-weight: 500; | |
| font-size: 14px; | |
| } | |
| .tab-btn:hover { | |
| background: rgba(102, 126, 234, 0.2); | |
| color: #b8c5ff; | |
| } | |
| .tab-btn.active { | |
| background: #667eea; | |
| color: white; | |
| } | |
| .tab-content { | |
| display: none; | |
| flex: 1; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| .tab-content.active { | |
| display: flex; | |
| } | |
| #chat-messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .message-wrapper { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| position: relative; | |
| } | |
| .message-wrapper.user-message { | |
| align-items: flex-end; | |
| } | |
| .message-wrapper.assistant-message, | |
| .message-wrapper.tool-message { | |
| align-items: flex-start; | |
| } | |
| .message-wrapper.system-message { | |
| align-items: center; | |
| } | |
| .message { | |
| padding: 12px 16px; | |
| border-radius: 8px; | |
| max-width: 85%; | |
| word-wrap: break-word; | |
| white-space: pre-wrap; | |
| line-height: 1.5; | |
| font-size: 14px; | |
| position: relative; | |
| transition: all 0.2s; | |
| } | |
| .message.excluded { | |
| opacity: 0.4; | |
| filter: grayscale(0.5); | |
| } | |
| .message.pinned { | |
| border: 2px solid #f59e0b; | |
| box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); | |
| } | |
| .message.editing { | |
| padding: 0; | |
| background: transparent !important; | |
| border: 2px solid #667eea; | |
| } | |
| .message.user { | |
| background: #667eea; | |
| color: white; | |
| } | |
| .message.assistant { | |
| background: #2a2a2a; | |
| color: #e0e0e0; | |
| } | |
| .message.system { | |
| background: #3a3a3a; | |
| color: #999; | |
| font-size: 12px; | |
| max-width: 90%; | |
| } | |
| .message.tool { | |
| background: #1f2937; | |
| color: #10b981; | |
| font-family: 'Courier New', monospace; | |
| font-size: 12px; | |
| border-left: 3px solid #10b981; | |
| } | |
| .message-controls { | |
| display: flex; | |
| gap: 4px; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| } | |
| .message-wrapper:hover .message-controls { | |
| opacity: 1; | |
| } | |
| .message-control-btn { | |
| padding: 4px 8px; | |
| background: #3a3a3a; | |
| border: none; | |
| border-radius: 4px; | |
| color: #999; | |
| cursor: pointer; | |
| font-size: 11px; | |
| transition: all 0.2s; | |
| } | |
| .message-control-btn:hover { | |
| background: #4a4a4a; | |
| color: #e0e0e0; | |
| } | |
| .message-control-btn.active { | |
| background: #667eea; | |
| color: white; | |
| } | |
| .message-control-btn.pinned { | |
| background: #f59e0b; | |
| color: white; | |
| } | |
| .message-control-btn.danger:hover { | |
| background: #ef4444; | |
| color: white; | |
| } | |
| .message-edit-area { | |
| width: 100%; | |
| padding: 12px; | |
| background: #2a2a2a; | |
| border: none; | |
| border-radius: 6px; | |
| color: white; | |
| font-family: inherit; | |
| font-size: 14px; | |
| resize: vertical; | |
| min-height: 60px; | |
| } | |
| .message-edit-controls { | |
| display: flex; | |
| gap: 4px; | |
| margin-top: 4px; | |
| } | |
| .edit-save-btn { | |
| padding: 4px 12px; | |
| background: #10b981; | |
| border: none; | |
| border-radius: 4px; | |
| color: white; | |
| cursor: pointer; | |
| font-size: 11px; | |
| } | |
| .edit-cancel-btn { | |
| padding: 4px 12px; | |
| background: #3a3a3a; | |
| border: none; | |
| border-radius: 4px; | |
| color: #999; | |
| cursor: pointer; | |
| font-size: 11px; | |
| } | |
| #chat-input-area { | |
| padding: 16px; | |
| background: #2a2a2a; | |
| border-top: 1px solid #3a3a3a; | |
| display: flex; | |
| gap: 8px; | |
| flex-direction: column; | |
| } | |
| .input-row { | |
| display: flex; | |
| gap: 8px; | |
| align-items: flex-end; | |
| } | |
| .action-buttons { | |
| display: flex; | |
| gap: 8px; | |
| justify-content: flex-end; | |
| flex-wrap: wrap; | |
| } | |
| .action-btn { | |
| padding: 6px 12px; | |
| background: #3a3a3a; | |
| border: none; | |
| border-radius: 6px; | |
| color: #999; | |
| cursor: pointer; | |
| font-size: 12px; | |
| transition: all 0.2s; | |
| font-weight: 500; | |
| } | |
| .action-btn:hover { | |
| background: #4a4a4a; | |
| color: #e0e0e0; | |
| } | |
| .action-btn:active { | |
| transform: scale(0.98); | |
| } | |
| #chat-input { | |
| flex: 1; | |
| padding: 12px; | |
| background: #1a1a1a; | |
| border: 1px solid #3a3a3a; | |
| border-radius: 6px; | |
| color: white; | |
| resize: none; | |
| font-family: inherit; | |
| font-size: 14px; | |
| transition: border-color 0.2s; | |
| } | |
| #chat-input:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| } | |
| #send-btn { | |
| padding: 12px 24px; | |
| background: #667eea; | |
| border: none; | |
| border-radius: 6px; | |
| color: white; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-weight: 500; | |
| font-size: 14px; | |
| } | |
| #send-btn:hover:not(:disabled) { | |
| background: #5568d3; | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); | |
| } | |
| #send-btn:active:not(:disabled) { | |
| transform: translateY(0); | |
| } | |
| #send-btn:disabled { | |
| background: #3a3a3a; | |
| cursor: not-allowed; | |
| opacity: 0.5; | |
| } | |
| .modal-overlay { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(0, 0, 0, 0.7); | |
| z-index: 1000000; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-overlay.active { | |
| display: flex; | |
| } | |
| .modal { | |
| background: #1a1a1a; | |
| border-radius: 12px; | |
| padding: 24px; | |
| max-width: 500px; | |
| width: 90%; | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.5); | |
| } | |
| .modal-title { | |
| color: #e0e0e0; | |
| font-size: 18px; | |
| font-weight: 600; | |
| margin-bottom: 16px; | |
| } | |
| .modal-textarea { | |
| width: 100%; | |
| min-height: 300px; | |
| padding: 12px; | |
| background: #2a2a2a; | |
| border: 1px solid #3a3a3a; | |
| border-radius: 6px; | |
| color: white; | |
| font-family: 'Courier New', monospace; | |
| font-size: 12px; | |
| resize: vertical; | |
| box-sizing: border-box; | |
| } | |
| .modal-buttons { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 16px; | |
| justify-content: flex-end; | |
| } | |
| .modal-btn { | |
| padding: 10px 20px; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-weight: 500; | |
| transition: all 0.2s; | |
| } | |
| .modal-btn.primary { | |
| background: #667eea; | |
| color: white; | |
| } | |
| .modal-btn.primary:hover { | |
| background: #5568d3; | |
| } | |
| .modal-btn.secondary { | |
| background: #3a3a3a; | |
| color: #999; | |
| } | |
| .modal-btn.secondary:hover { | |
| background: #4a4a4a; | |
| color: #e0e0e0; | |
| } | |
| .spinner { | |
| border: 2px solid #3a3a3a; | |
| border-top: 2px solid #667eea; | |
| border-radius: 50%; | |
| width: 16px; | |
| height: 16px; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| #settings-content { | |
| padding: 20px; | |
| overflow-y: auto; | |
| } | |
| .setting-group { | |
| margin-bottom: 24px; | |
| } | |
| .setting-label { | |
| color: #e0e0e0; | |
| font-size: 14px; | |
| font-weight: 600; | |
| margin-bottom: 8px; | |
| display: block; | |
| } | |
| .setting-description { | |
| color: #999; | |
| font-size: 12px; | |
| margin-top: 4px; | |
| display: block; | |
| line-height: 1.4; | |
| } | |
| .setting-input, .setting-textarea { | |
| width: 100%; | |
| padding: 10px 12px; | |
| background: #2a2a2a; | |
| border: 1px solid #3a3a3a; | |
| border-radius: 6px; | |
| color: white; | |
| font-family: inherit; | |
| box-sizing: border-box; | |
| font-size: 14px; | |
| transition: border-color 0.2s; | |
| } | |
| .setting-input:focus, .setting-textarea:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| } | |
| .setting-input[type="number"] { | |
| max-width: 200px; | |
| } | |
| .setting-textarea { | |
| resize: vertical; | |
| min-height: 200px; | |
| font-size: 12px; | |
| font-family: 'Courier New', monospace; | |
| line-height: 1.5; | |
| } | |
| .save-btn { | |
| width: 100%; | |
| padding: 12px; | |
| background: #667eea; | |
| border: none; | |
| border-radius: 6px; | |
| color: white; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 14px; | |
| transition: all 0.2s; | |
| } | |
| .save-btn:hover { | |
| background: #5568d3; | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); | |
| } | |
| .save-btn:active { | |
| transform: translateY(0); | |
| } | |
| #chat-messages::-webkit-scrollbar, #settings-content::-webkit-scrollbar, .modal::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| #chat-messages::-webkit-scrollbar-track, #settings-content::-webkit-scrollbar-track, .modal::-webkit-scrollbar-track { | |
| background: #1a1a1a; | |
| } | |
| #chat-messages::-webkit-scrollbar-thumb, #settings-content::-webkit-scrollbar-thumb, .modal::-webkit-scrollbar-thumb { | |
| background: #3a3a3a; | |
| border-radius: 4px; | |
| } | |
| #chat-messages::-webkit-scrollbar-thumb:hover, #settings-content::-webkit-scrollbar-thumb:hover, .modal::-webkit-scrollbar-thumb:hover { | |
| background: #4a4a4a; | |
| } | |
| </style> | |
| <button id="ai-agent-toggle">🤖</button> | |
| <div id="ai-agent-window"> | |
| <div class="agent-header"> | |
| <button class="tab-btn active" data-tab="chat">Chat</button> | |
| <button class="tab-btn" data-tab="settings">Settings</button> | |
| </div> | |
| <div id="chat-tab" class="tab-content active"> | |
| <div id="chat-messages"></div> | |
| <div id="chat-input-area"> | |
| <div class="action-buttons"> | |
| <button class="action-btn" id="clear-chat-btn">Clear Chat</button> | |
| <button class="action-btn" id="export-chat-btn">Export</button> | |
| <button class="action-btn" id="import-chat-btn">Import</button> | |
| <button class="action-btn" id="inject-message-btn">Inject</button> | |
| <button class="action-btn" id="toggle-debug-btn">Debug: OFF</button> | |
| </div> | |
| <div class="input-row"> | |
| <textarea id="chat-input" placeholder="Ask me anything..." rows="2"></textarea> | |
| <button id="send-btn">Send</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="settings-tab" class="tab-content"> | |
| <div id="settings-content"> | |
| <div class="setting-group"> | |
| <label class="setting-label">OpenRouter API Key</label> | |
| <input type="password" id="api-key-input" class="setting-input" placeholder="sk-or-v1-..." value="${this.apiKey}"> | |
| <small class="setting-description">Get your API key from <a href="https://openrouter.ai/keys" target="_blank" style="color: #667eea;">openrouter.ai/keys</a></small> | |
| </div> | |
| <div class="setting-group"> | |
| <label class="setting-label">Model</label> | |
| <input type="text" id="model-input" class="setting-input" placeholder="anthropic/claude-3.5-sonnet" value="${this.model}"> | |
| <small class="setting-description">Examples: anthropic/claude-3.5-sonnet, openai/gpt-4, google/gemini-pro</small> | |
| </div> | |
| <div class="setting-group"> | |
| <label class="setting-label">Max ReACT Iterations</label> | |
| <input type="number" id="max-iterations-input" class="setting-input" min="1" max="50" value="${this.maxIterations}"> | |
| <small class="setting-description">Maximum number of tool execution cycles before stopping (1-50). Higher values allow more complex reasoning chains but increase API usage.</small> | |
| </div> | |
| <div class="setting-group"> | |
| <label class="setting-label">System Prompt</label> | |
| <textarea id="system-prompt-input" class="setting-textarea">${this.systemPrompt}</textarea> | |
| <small class="setting-description">Customize how the AI agent behaves and responds</small> | |
| </div> | |
| <button class="save-btn" id="save-settings">Save Settings</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal-overlay" id="import-modal"> | |
| <div class="modal"> | |
| <div class="modal-title">Import Chat History</div> | |
| <textarea class="modal-textarea" id="import-textarea" placeholder="Paste JSON here..."></textarea> | |
| <div class="modal-buttons"> | |
| <button class="modal-btn secondary" id="import-cancel">Cancel</button> | |
| <button class="modal-btn primary" id="import-confirm">Import</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="modal-overlay" id="inject-modal"> | |
| <div class="modal"> | |
| <div class="modal-title">Inject Message</div> | |
| <div style="margin-bottom: 12px;"> | |
| <label style="color: #e0e0e0; font-size: 12px; display: block; margin-bottom: 4px;">Role</label> | |
| <select id="inject-role" style="width: 100%; padding: 8px; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; color: white; font-size: 14px;"> | |
| <option value="user">User</option> | |
| <option value="assistant">Assistant</option> | |
| <option value="system">System</option> | |
| </select> | |
| </div> | |
| <textarea class="modal-textarea" id="inject-textarea" placeholder="Enter message content..."></textarea> | |
| <div class="modal-buttons"> | |
| <button class="modal-btn secondary" id="inject-cancel">Cancel</button> | |
| <button class="modal-btn primary" id="inject-confirm">Inject</button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(container); | |
| } | |
| attachEventListeners() { | |
| const toggle = document.getElementById("ai-agent-toggle"); | |
| const window = document.getElementById("ai-agent-window"); | |
| const tabBtns = document.querySelectorAll(".tab-btn"); | |
| const sendBtn = document.getElementById("send-btn"); | |
| const input = document.getElementById("chat-input"); | |
| const saveBtn = document.getElementById("save-settings"); | |
| const clearBtn = document.getElementById("clear-chat-btn"); | |
| const debugBtn = document.getElementById("toggle-debug-btn"); | |
| const exportBtn = document.getElementById("export-chat-btn"); | |
| const importBtn = document.getElementById("import-chat-btn"); | |
| const injectBtn = document.getElementById("inject-message-btn"); | |
| toggle.addEventListener("click", () => { | |
| this.isOpen = !this.isOpen; | |
| window.classList.toggle("open", this.isOpen); | |
| }); | |
| tabBtns.forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| const tab = btn.dataset.tab; | |
| this.switchTab(tab); | |
| }); | |
| }); | |
| sendBtn.addEventListener("click", () => this.sendMessage()); | |
| input.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| this.sendMessage(); | |
| } | |
| }); | |
| clearBtn.addEventListener("click", () => this.clearChat()); | |
| debugBtn.addEventListener("click", () => this.toggleDebug()); | |
| exportBtn.addEventListener("click", () => this.exportChat()); | |
| importBtn.addEventListener("click", () => this.showImportModal()); | |
| injectBtn.addEventListener("click", () => this.showInjectModal()); | |
| saveBtn.addEventListener("click", () => this.saveSettings()); | |
| // Import modal | |
| document.getElementById("import-cancel").addEventListener("click", () => { | |
| this.hideModal("import-modal"); | |
| }); | |
| document.getElementById("import-confirm").addEventListener("click", () => { | |
| this.importChat(); | |
| }); | |
| // Inject modal | |
| document.getElementById("inject-cancel").addEventListener("click", () => { | |
| this.hideModal("inject-modal"); | |
| }); | |
| document.getElementById("inject-confirm").addEventListener("click", () => { | |
| this.injectMessage(); | |
| }); | |
| // Close modal on overlay click | |
| document.querySelectorAll(".modal-overlay").forEach(overlay => { | |
| overlay.addEventListener("click", (e) => { | |
| if (e.target === overlay) { | |
| this.hideModal(overlay.id); | |
| } | |
| }); | |
| }); | |
| } | |
| switchTab(tab) { | |
| this.currentTab = tab; | |
| document.querySelectorAll(".tab-btn").forEach(btn => { | |
| btn.classList.toggle("active", btn.dataset.tab === tab); | |
| }); | |
| document.querySelectorAll(".tab-content").forEach(content => { | |
| content.classList.toggle("active", content.id === `${tab}-tab`); | |
| }); | |
| } | |
| clearChat() { | |
| if (confirm("Clear all messages?")) { | |
| this.messageManager.clear(); | |
| this.log("Chat cleared"); | |
| } | |
| } | |
| exportChat() { | |
| const json = this.messageManager.exportMessages(); | |
| const blob = new Blob([json], { type: "application/json" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = `chat-export-${Date.now()}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| this.log("Chat exported"); | |
| } | |
| showImportModal() { | |
| document.getElementById("import-textarea").value = ""; | |
| this.showModal("import-modal"); | |
| } | |
| importChat() { | |
| const json = document.getElementById("import-textarea").value; | |
| if (this.messageManager.importMessages(json)) { | |
| this.hideModal("import-modal"); | |
| this.log("Chat imported successfully"); | |
| } else { | |
| alert("Failed to import. Please check the JSON format."); | |
| } | |
| } | |
| showInjectModal() { | |
| document.getElementById("inject-textarea").value = ""; | |
| document.getElementById("inject-role").value = "user"; | |
| this.showModal("inject-modal"); | |
| } | |
| injectMessage() { | |
| const role = document.getElementById("inject-role").value; | |
| const content = document.getElementById("inject-textarea").value.trim(); | |
| if (content) { | |
| this.messageManager.addMessage(role, content); | |
| this.hideModal("inject-modal"); | |
| this.log(`Injected ${role} message`); | |
| } | |
| } | |
| showModal(modalId) { | |
| document.getElementById(modalId).classList.add("active"); | |
| } | |
| hideModal(modalId) { | |
| document.getElementById(modalId).classList.remove("active"); | |
| } | |
| toggleDebug() { | |
| this.debugMode = !this.debugMode; | |
| const btn = document.getElementById("toggle-debug-btn"); | |
| btn.textContent = `Debug: ${this.debugMode ? "ON" : "OFF"}`; | |
| this.log(`Debug mode ${this.debugMode ? "enabled" : "disabled"}`); | |
| } | |
| log(message, data = null) { | |
| if (this.debugMode) { | |
| console.log(`[AI Agent] ${message}`, data || ""); | |
| } | |
| } | |
| renderMessages() { | |
| const messagesDiv = document.getElementById("chat-messages"); | |
| const scrollAtBottom = messagesDiv.scrollHeight - messagesDiv.scrollTop <= messagesDiv.clientHeight + 50; | |
| messagesDiv.innerHTML = ""; | |
| this.messageManager.messages.forEach(msg => { | |
| const wrapper = this.createMessageElement(msg); | |
| messagesDiv.appendChild(wrapper); | |
| }); | |
| if (scrollAtBottom) { | |
| messagesDiv.scrollTop = messagesDiv.scrollHeight; | |
| } | |
| } | |
| createMessageElement(msg) { | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = `message-wrapper ${msg.role}-message`; | |
| if (this.editingMessageId === msg.id) { | |
| wrapper.innerHTML = ` | |
| <div class="message ${msg.role} editing"> | |
| <textarea class="message-edit-area" id="edit-area-${msg.id}">${msg.content}</textarea> | |
| </div> | |
| <div class="message-edit-controls"> | |
| <button class="edit-save-btn" data-id="${msg.id}">Save</button> | |
| <button class="edit-cancel-btn" data-id="${msg.id}">Cancel</button> | |
| </div> | |
| `; | |
| setTimeout(() => { | |
| const saveBtn = wrapper.querySelector(".edit-save-btn"); | |
| const cancelBtn = wrapper.querySelector(".edit-cancel-btn"); | |
| const textarea = wrapper.querySelector(".message-edit-area"); | |
| saveBtn.addEventListener("click", () => this.saveMessageEdit(msg.id)); | |
| cancelBtn.addEventListener("click", () => this.cancelMessageEdit()); | |
| textarea.focus(); | |
| }, 0); | |
| } else { | |
| const msgClasses = ["message", msg.role]; | |
| if (!msg.included) msgClasses.push("excluded"); | |
| if (msg.pinned) msgClasses.push("pinned"); | |
| wrapper.innerHTML = ` | |
| <div class="${msgClasses.join(" ")}">${msg.content}</div> | |
| <div class="message-controls"> | |
| <button class="message-control-btn ${msg.included ? "" : "active"}" data-action="toggle" data-id="${msg.id}"> | |
| ${msg.included ? "Exclude" : "Include"} | |
| </button> | |
| <button class="message-control-btn ${msg.pinned ? "pinned" : ""}" data-action="pin" data-id="${msg.id}"> | |
| ${msg.pinned ? "📌 Pinned" : "📌 Pin"} | |
| </button> | |
| <button class="message-control-btn" data-action="edit" data-id="${msg.id}">✏️ Edit</button> | |
| <button class="message-control-btn danger" data-action="delete" data-id="${msg.id}">🗑️ Delete</button> | |
| </div> | |
| `; | |
| const controls = wrapper.querySelectorAll(".message-control-btn"); | |
| controls.forEach(btn => { | |
| btn.addEventListener("click", () => this.handleMessageControl(btn.dataset.action, msg.id)); | |
| }); | |
| } | |
| return wrapper; | |
| } | |
| handleMessageControl(action, id) { | |
| switch (action) { | |
| case "toggle": | |
| this.messageManager.toggleMessageInclusion(id); | |
| this.log("Toggled message inclusion"); | |
| break; | |
| case "pin": | |
| this.messageManager.toggleMessagePin(id); | |
| this.log("Toggled message pin"); | |
| break; | |
| case "edit": | |
| this.startMessageEdit(id); | |
| break; | |
| case "delete": | |
| if (confirm("Delete this message?")) { | |
| this.messageManager.removeMessage(id); | |
| this.log("Deleted message"); | |
| } | |
| break; | |
| } | |
| } | |
| startMessageEdit(id) { | |
| this.editingMessageId = id; | |
| this.renderMessages(); | |
| } | |
| saveMessageEdit(id) { | |
| const textarea = document.getElementById(`edit-area-${id}`); | |
| const newContent = textarea.value.trim(); | |
| if (newContent) { | |
| this.messageManager.updateMessage(id, { "content": newContent }); | |
| this.log("Message updated"); | |
| } | |
| this.editingMessageId = null; | |
| this.renderMessages(); | |
| } | |
| cancelMessageEdit() { | |
| this.editingMessageId = null; | |
| this.renderMessages(); | |
| } | |
| executeJS(code) { | |
| this.log("Executing JS:", code); | |
| try { | |
| const result = eval(code); | |
| const output = { | |
| "success": true, | |
| "result": result !== undefined ? String(result) : "undefined" | |
| }; | |
| this.log("JS result:", output); | |
| return output; | |
| } catch (error) { | |
| const output = { | |
| "success": false, | |
| "error": error.message, | |
| "stack": error.stack | |
| }; | |
| this.log("JS error:", output); | |
| return output; | |
| } | |
| } | |
| getPageInfo() { | |
| const info = { | |
| "url": window.location.href, | |
| "title": document.title, | |
| "domain": window.location.hostname | |
| }; | |
| this.log("Page info:", info); | |
| return info; | |
| } | |
| fetchURL(url) { | |
| this.log("Fetching URL:", url); | |
| return new Promise((resolve) => { | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url: url, | |
| onload: (response) => { | |
| const result = { | |
| "success": true, | |
| "status": response.status, | |
| "content": response.responseText, | |
| "contentLength": response.responseText.length | |
| }; | |
| this.log("Fetch success:", { | |
| "url": url, | |
| "status": result.status, | |
| "length": result.contentLength | |
| }); | |
| resolve(result); | |
| }, | |
| onerror: (error) => { | |
| const result = { | |
| "success": false, | |
| "error": "Network error or invalid URL" | |
| }; | |
| this.log("Fetch error:", result); | |
| resolve(result); | |
| }, | |
| ontimeout: () => { | |
| const result = { | |
| "success": false, | |
| "error": "Request timeout" | |
| }; | |
| this.log("Fetch timeout:", result); | |
| resolve(result); | |
| } | |
| }); | |
| }); | |
| } | |
| async executeTool(toolName, params) { | |
| this.log(`Executing tool: ${toolName}`, params); | |
| let result; | |
| switch (toolName) { | |
| case "execute_js": | |
| result = this.executeJS(params.code); | |
| break; | |
| case "get_page_info": | |
| result = this.getPageInfo(); | |
| break; | |
| case "fetch_url": | |
| result = await this.fetchURL(params.url); | |
| break; | |
| default: | |
| result = {"error": `Unknown tool: ${toolName}`}; | |
| } | |
| this.log("Tool result:", result); | |
| return result; | |
| } | |
| parseToolCall(text) { | |
| this.log("Parsing tool call from text:", text.substring(0, 300)); | |
| const actionMatch = text.match(/Action:\s*(\{)/); | |
| if (!actionMatch) { | |
| this.log("No 'Action:' found in text"); | |
| return null; | |
| } | |
| const startIdx = text.indexOf("{", actionMatch.index); | |
| if (startIdx === -1) { | |
| this.log("No opening brace found"); | |
| return null; | |
| } | |
| let braceCount = 0; | |
| let endIdx = startIdx; | |
| let inString = false; | |
| let escapeNext = false; | |
| for (let i = startIdx; i < text.length; i++) { | |
| const char = text[i]; | |
| if (escapeNext) { | |
| escapeNext = false; | |
| continue; | |
| } | |
| if (char === "\\") { | |
| escapeNext = true; | |
| continue; | |
| } | |
| if (char === "\"") { | |
| inString = !inString; | |
| continue; | |
| } | |
| if (!inString) { | |
| if (char === "{") braceCount++; | |
| if (char === "}") { | |
| braceCount--; | |
| if (braceCount === 0) { | |
| endIdx = i + 1; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| if (braceCount !== 0) { | |
| this.log("Unmatched braces in JSON"); | |
| return null; | |
| } | |
| const jsonStr = text.substring(startIdx, endIdx); | |
| this.log("Extracted JSON string:", jsonStr.substring(0, 200)); | |
| try { | |
| const parsed = JSON.parse(jsonStr); | |
| this.log("Successfully parsed tool call:", parsed); | |
| return parsed; | |
| } catch (e) { | |
| this.log("Failed to parse JSON:", e.message); | |
| this.log("JSON string was:", jsonStr); | |
| return null; | |
| } | |
| } | |
| async sendMessage() { | |
| if (this.isProcessing) return; | |
| const input = document.getElementById("chat-input"); | |
| const sendBtn = document.getElementById("send-btn"); | |
| const message = input.value.trim(); | |
| if (!message) return; | |
| if (!this.apiKey) { | |
| this.messageManager.addMessage("system", "Please add your OpenRouter API key in Settings"); | |
| return; | |
| } | |
| input.value = ""; | |
| sendBtn.disabled = true; | |
| this.isProcessing = true; | |
| this.messageManager.addMessage("user", message); | |
| await this.processWithReACT(); | |
| this.isProcessing = false; | |
| sendBtn.disabled = false; | |
| input.focus(); | |
| } | |
| async processWithReACT() { | |
| let iteration = 0; | |
| this.log("Starting ReACT loop"); | |
| while (iteration < this.maxIterations) { | |
| iteration++; | |
| this.log(`ReACT iteration ${iteration}/${this.maxIterations}`); | |
| try { | |
| this.log("Calling API with included messages:", this.messageManager.getIncludedMessages().length); | |
| const response = await this.callAPI(); | |
| const assistantMsg = response.choices[0].message.content; | |
| this.log("API response:", assistantMsg); | |
| const toolCall = this.parseToolCall(assistantMsg); | |
| if (toolCall && toolCall.tool) { | |
| this.log("Tool call detected:", toolCall); | |
| this.messageManager.addMessage("assistant", assistantMsg); | |
| const toolResult = await this.executeTool(toolCall.tool, toolCall); | |
| const observation = `Observation: ${JSON.stringify(toolResult, null, 2)}`; | |
| this.log("Adding observation to chat"); | |
| // Store as user message since we're formatting observations as text | |
| this.messageManager.addMessage("user", observation); | |
| } else { | |
| this.log("No tool call found - final answer"); | |
| this.messageManager.addMessage("assistant", assistantMsg); | |
| break; | |
| } | |
| } catch (error) { | |
| this.log("Error in ReACT loop:", error); | |
| this.messageManager.addMessage("system", `Error: ${error.message}`); | |
| break; | |
| } | |
| } | |
| if (iteration >= this.maxIterations) { | |
| this.log("Max iterations reached"); | |
| this.messageManager.addMessage("system", `Max iterations (${this.maxIterations}) reached. Stopping.`); | |
| } | |
| this.log("ReACT loop complete"); | |
| } | |
| callAPI() { | |
| return new Promise((resolve, reject) => { | |
| const includedMessages = this.messageManager.getIncludedMessages(); | |
| const pinnedMessages = this.messageManager.getPinnedMessages(); | |
| // Build message array: pinned messages first, then included messages | |
| const messageContent = [ | |
| ...pinnedMessages.map(m => ({"role": m.role === "tool" ? "user" : m.role, "content": m.content})), | |
| ...includedMessages | |
| .filter(m => !m.pinned) // Don't duplicate pinned messages | |
| .map(m => ({"role": m.role === "tool" ? "user" : m.role, "content": m.content})) | |
| ]; | |
| const messages = [ | |
| {"role": "system", "content": this.systemPrompt}, | |
| ...messageContent | |
| ]; | |
| this.log("API request:", { | |
| "model": this.model, | |
| "messageCount": messages.length, | |
| "pinnedCount": pinnedMessages.length | |
| }); | |
| GM_xmlhttpRequest({ | |
| method: "POST", | |
| url: "https://openrouter.ai/api/v1/chat/completions", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Authorization": `Bearer ${this.apiKey}` | |
| }, | |
| data: JSON.stringify({ | |
| "model": this.model, | |
| "messages": messages, | |
| "temperature": 0.7 | |
| }), | |
| onload: (response) => { | |
| this.log("API response status:", response.status); | |
| if (response.status === 200) { | |
| const data = JSON.parse(response.responseText); | |
| this.log("API response data:", data); | |
| resolve(data); | |
| } else { | |
| this.log("API error response:", response.responseText); | |
| reject(new Error(`API error: ${response.status} - ${response.responseText}`)); | |
| } | |
| }, | |
| onerror: (error) => { | |
| this.log("API network error:", error); | |
| reject(new Error("Network error")); | |
| } | |
| }); | |
| }); | |
| } | |
| saveSettings() { | |
| const apiKeyInput = document.getElementById("api-key-input"); | |
| const modelInput = document.getElementById("model-input"); | |
| const systemPromptInput = document.getElementById("system-prompt-input"); | |
| const maxIterationsInput = document.getElementById("max-iterations-input"); | |
| this.apiKey = apiKeyInput.value.trim(); | |
| this.model = modelInput.value.trim() || DEFAULT_MODEL; | |
| this.systemPrompt = systemPromptInput.value.trim(); | |
| const maxIterations = parseInt(maxIterationsInput.value, 10); | |
| this.maxIterations = (maxIterations >= 1 && maxIterations <= 50) ? maxIterations : DEFAULT_MAX_ITERATIONS; | |
| maxIterationsInput.value = this.maxIterations; | |
| GM_setValue("apiKey", this.apiKey); | |
| GM_setValue("model", this.model); | |
| GM_setValue("systemPrompt", this.systemPrompt); | |
| GM_setValue("maxIterations", this.maxIterations); | |
| this.messageManager.addMessage("system", "Settings saved"); | |
| this.switchTab("chat"); | |
| } | |
| } | |
| window.addEventListener("load", () => { | |
| new BrowserAgent(); | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment