commit ffda90ce160da52cdb869b427c83ebb5d59a346f Author: elijah <146715005+schizoposter@users.noreply.github.com> Date: Thu Oct 24 15:20:04 2024 +0200 v1.2 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e49507 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# CAS + +A simple, versatile language processing interface for websites. + +## Features +- Configurable + - Use own API + - Adjust opacity of responses +- Hidden + - Only visible to those who know where to look + +## Usage +After installing the extensions, selecting text and pressing `Ctrl+Space` will display the answer above the selected text in the configured opacity. To hide the answer, press `Ctrl+Space` again without selecting any text. +Additionally, pressing `Ctrl+Shift+Space` will write the answer into the nearest text field. + +## Configuration +The extension can be configured by clicking on the extension icon in the toolbar. +You will need to add your own OpenAI Endpoint (for example `https://api.elia.network`), and an API key. +There is an default URL and Key which is restricted to llama3.2-90b and 10 Requests per minute. +The opacity can be adjusted from 0 to 1, and is set to 0.1 per default. Adjust based on your screen's brightness and if you want to risk it being spotted. + +## Installation diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..b51288f --- /dev/null +++ b/src/background.js @@ -0,0 +1,8 @@ +// background.js +browser.commands.onCommand.addListener((command) => { + if (command === "print-selection" || command === "fill-input") { + browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { + browser.tabs.sendMessage(tabs[0].id, { action: command }); + }); + } +}); diff --git a/src/content.js b/src/content.js new file mode 100644 index 0000000..c3003d2 --- /dev/null +++ b/src/content.js @@ -0,0 +1,326 @@ +// Constants +const DEFAULT_SETTINGS = { + apiUrl: "https://api.elia.network", + apiKey: "sk-TvFhtxHAPXEcmRtyva-ctA", + textOpacity: 10, + model: "llama3.2-90b", +}; + +const MAX_CONTEXT_LENGTH = 6000; + +// State +let overlay = null; +let isVisible = false; +let lastKnownSelection = null; +let lastKnownRange = null; +let scrollTimeout = null; +let resizeRAF = null; + +// Utility Functions +const getPageContext = () => { + const bodyText = document.body.innerText; + return bodyText.length > MAX_CONTEXT_LENGTH + ? `${bodyText.substring(0, MAX_CONTEXT_LENGTH)}...` + : bodyText; +}; + +const loadSettings = async () => { + try { + const data = await browser.storage.sync.get("settings"); + return data.settings || DEFAULT_SETTINGS; + } catch (error) { + console.error("Error loading settings:", error); + return DEFAULT_SETTINGS; + } +}; + +// API Functions +const queryLLM = async (text) => { + const settings = await loadSettings(); + const pageContext = getPageContext(); + const response = await fetch(`${settings.apiUrl}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.apiKey}`, + }, + body: JSON.stringify({ + model: settings.model, + messages: [ + { + role: "user", + content: `You are tasked to solve an exam. Exam's page context: ${pageContext}\n\Answer this question of said exam in very short but accurate manner (Utilize context to figure out the exam's topic. ONLY REPLY WITH ANSWER TO THE QUESTION): ${text}`, + }, + ], + temperature: 0.1, + }), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error( + `HTTP error! status: ${response.status}${errorData ? `, message: ${JSON.stringify(errorData)}` : ""}`, + ); + } + + const data = await response.json(); + return data.choices[0].message.content; +}; + +// UI Functions +const createOverlay = (text) => { + if (overlay) overlay.remove(); + + overlay = document.createElement("div"); + overlay.textContent = text; + overlay.className = "llm-overlay"; + + loadSettings().then((settings) => { + overlay.style.cssText = ` + position: absolute; + padding: 10px; + z-index: 2147483647; + color: rgba(0, 0, 0, ${settings.textOpacity / 100}); + font-size: 14px; + max-width: 600px; + white-space: pre-wrap; + pointer-events: none; + `; + + document.body.appendChild(overlay); + requestAnimationFrame(positionOverlay); + }); + + return overlay; +}; + +const positionOverlay = () => { + if (!overlay || !isVisible) return; + + try { + const selection = window.getSelection(); + if (!selection.rangeCount) return; + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // Get scroll positions + const scrollX = window.pageXOffset || document.documentElement.scrollLeft; + const scrollY = window.pageYOffset || document.documentElement.scrollTop; + + // Get viewport dimensions + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth; + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight; + + // Calculate overlay dimensions + const overlayWidth = overlay.offsetWidth; + const overlayHeight = overlay.offsetHeight; + + // Calculate positions + let top = rect.top + scrollY; + let left = rect.left + scrollX + rect.width / 2 - overlayWidth / 2; + + // Position above selection by default + top -= overlayHeight + 5; + + // If overlay would go above viewport, position it below selection + if (top - scrollY < 0) { + top = rect.bottom + scrollY + 5; + } + + // Keep overlay within horizontal bounds + left = Math.max( + scrollX + 5, + Math.min(left, scrollX + viewportWidth - overlayWidth - 10), + ); + + // Apply positions + overlay.style.top = `${top}px`; + overlay.style.left = `${left}px`; + } catch (error) { + console.error("Error positioning overlay:", error); + } +}; + +// Event Handlers +const handleScroll = () => { + if (!isVisible) return; + + if (scrollTimeout) { + cancelAnimationFrame(scrollTimeout); + } + + scrollTimeout = requestAnimationFrame(positionOverlay); +}; + +const handleResize = () => { + if (!isVisible) return; + + if (resizeRAF) { + cancelAnimationFrame(resizeRAF); + } + + resizeRAF = requestAnimationFrame(positionOverlay); +}; + +// Initialize Event Listeners +window.addEventListener("resize", handleResize, { passive: true }); + +const attachScrollListeners = () => { + // Listen to window scroll + window.addEventListener("scroll", handleScroll, { passive: true }); + + // Listen to scroll events on all scrollable elements + document.addEventListener("scroll", handleScroll, { + capture: true, + passive: true, + }); + + // Find and attach listeners to all scrollable containers + const scrollableElements = document.querySelectorAll( + [ + '*[style*="overflow: auto"]', + '*[style*="overflow:auto"]', + '*[style*="overflow-y: auto"]', + '*[style*="overflow-y:auto"]', + '*[style*="overflow: scroll"]', + '*[style*="overflow:scroll"]', + '*[style*="overflow-y: scroll"]', + '*[style*="overflow-y:scroll"]', + ].join(","), + ); + + scrollableElements.forEach((element) => { + element.addEventListener("scroll", handleScroll, { passive: true }); + }); + + // Handle dynamic content changes + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.addedNodes.length) { + const newScrollableElements = document.querySelectorAll( + [ + '*[style*="overflow: auto"]', + '*[style*="overflow:auto"]', + '*[style*="overflow-y: auto"]', + '*[style*="overflow-y:auto"]', + '*[style*="overflow: scroll"]', + '*[style*="overflow:scroll"]', + '*[style*="overflow-y: scroll"]', + '*[style*="overflow-y:scroll"]', + ].join(","), + ); + + newScrollableElements.forEach((element) => { + if (!element.hasScrollListener) { + element.addEventListener("scroll", handleScroll, { passive: true }); + element.hasScrollListener = true; + } + }); + } + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); +}; + +// Initialize scroll listeners +attachScrollListeners(); + +// Message Handler +browser.runtime.onMessage.addListener((message) => { + if (message.action === "print-selection") { + toggleOverlay(); + } else if (message.action === "fill-input") { + modifyNearestInput(); + } +}); + +// Main Functions +const toggleOverlay = async () => { + const selection = window.getSelection(); + const selectedText = selection.toString().trim(); + + if (!selectedText) { + if (overlay) { + overlay.remove(); + overlay = null; + } + isVisible = false; + return; + } + + isVisible = true; + createOverlay("Processing..."); + + try { + const llmResponse = await queryLLM(selectedText); + createOverlay(llmResponse); + } catch (error) { + console.error("Error:", error); + createOverlay("Error processing request"); + } +}; + +function findNearestInput(selectionNode) { + const inputs = Array.from( + document.querySelectorAll( + 'input[type="text"], input:not([type]), textarea', + ), + ); + + if (!inputs.length) return null; + + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + const selectionX = rect.left + rect.width / 2; + const selectionY = rect.top + rect.height / 2; + + let nearestInput = null; + let shortestDistance = Infinity; + + inputs.forEach((input) => { + const inputRect = input.getBoundingClientRect(); + const inputX = inputRect.left + inputRect.width / 2; + const inputY = inputRect.top + inputRect.height / 2; + + const distance = Math.sqrt( + Math.pow(selectionX - inputX, 2) + Math.pow(selectionY - inputY, 2), + ); + + if (distance < shortestDistance) { + shortestDistance = distance; + nearestInput = input; + } + }); + + return nearestInput; +} + +const modifyNearestInput = async () => { + const selection = window.getSelection(); + const selectedText = selection.toString().trim(); + if (!selectedText) return; + + const nearestInput = findNearestInput(selection.anchorNode); + if (!nearestInput) { + alert("No input field found nearby"); + return; + } + + try { + const oldPlaceholder = nearestInput.placeholder; + nearestInput.placeholder = "Processing..."; + const llmResponse = await queryLLM(selectedText); + nearestInput.value = llmResponse; + nearestInput.placeholder = oldPlaceholder; + } catch (error) { + console.error("Error:", error); + nearestInput.value = "Error processing request"; + } +}; diff --git a/src/icons/icon128.png b/src/icons/icon128.png new file mode 100644 index 0000000..50105d5 Binary files /dev/null and b/src/icons/icon128.png differ diff --git a/src/icons/icon16.png b/src/icons/icon16.png new file mode 100644 index 0000000..929d05a Binary files /dev/null and b/src/icons/icon16.png differ diff --git a/src/icons/icon32.png b/src/icons/icon32.png new file mode 100644 index 0000000..0778348 Binary files /dev/null and b/src/icons/icon32.png differ diff --git a/src/icons/icon48.png b/src/icons/icon48.png new file mode 100644 index 0000000..ce39acb Binary files /dev/null and b/src/icons/icon48.png differ diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..ea56ca6 --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,49 @@ +{ + "manifest_version": 2, + "name": "CAS", + "version": "1.2", + "description": "Language processing interface", + "background": { + "scripts": ["background.js"] + }, + "browser_specific_settings": { + "gecko": { + "id": "cas@elia.network", + "strict_min_version": "42.0" + } + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"] + } + ], + "permissions": ["activeTab", "storage", "*://api.elia.network/*"], + "commands": { + "print-selection": { + "suggested_key": { + "default": "Ctrl+Space" + }, + "description": "Process question" + }, + "fill-input": { + "suggested_key": { + "default": "Ctrl+Shift+Space" + }, + "description": "Process question and fill into nearest input field" + } + }, + "browser_action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/src/popup.html b/src/popup.html new file mode 100644 index 0000000..004edbe --- /dev/null +++ b/src/popup.html @@ -0,0 +1,268 @@ + + + + CAS + + + +
+

CAS

+ v1.2 +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + 50% +
+
+ +
+
+ Solve Question +
+ + + U +
+
+
+ Fill Answer +
+ + + 7 +
+
+
+ + +
+ +
+ + + + diff --git a/src/popup.js b/src/popup.js new file mode 100644 index 0000000..514f62f --- /dev/null +++ b/src/popup.js @@ -0,0 +1,80 @@ +const DEFAULT_SETTINGS = { + apiUrl: "https://api.elia.network", + apiKey: "sk-TvFhtxHAPXEcmRtyva-ctA", + textOpacity: 10, + model: "llama3.2-90b", +}; + +// Load settings when the popup opens +document.addEventListener("DOMContentLoaded", async () => { + try { + const settings = await loadSettings(); + + // Populate form fields + document.getElementById("apiUrl").value = settings.apiUrl; + document.getElementById("model").value = settings.model; + document.getElementById("apiKey").value = settings.apiKey; + document.getElementById("textOpacity").value = settings.textOpacity; + document.getElementById("opacityValue").textContent = + `${settings.textOpacity}%`; + } catch (error) { + console.error("Error loading settings:", error); + showStatus("Error loading settings", "error"); + } +}); + +// Handle opacity slider changes +document.getElementById("textOpacity").addEventListener("input", (e) => { + document.getElementById("opacityValue").textContent = `${e.target.value}%`; +}); + +// Handle form submission +document + .getElementById("settingsForm") + .addEventListener("submit", async (e) => { + e.preventDefault(); + + const settings = { + apiUrl: document.getElementById("apiUrl").value, + apiKey: document.getElementById("apiKey").value, + model: document.getElementById("model").value, + textOpacity: parseInt(document.getElementById("textOpacity").value), + }; + + try { + await browser.storage.sync.set({ settings }); + showStatus("Settings saved successfully!", "success"); + } catch (error) { + console.error("Error saving settings:", error); + showStatus("Error saving settings", "error"); + } + }); + +// Load settings from storage or use defaults +async function loadSettings() { + try { + const data = await browser.storage.sync.get("settings"); + return { ...DEFAULT_SETTINGS, ...data.settings }; + } catch (error) { + console.error("Error loading settings:", error); + return DEFAULT_SETTINGS; + } +} + +// Show status message +function showStatus(message, type) { + const status = document.getElementById("status"); + status.textContent = message; + status.className = type; + status.style.display = "block"; + + setTimeout(() => { + status.style.display = "none"; + }, 2000); +} + +// Handle errors +window.addEventListener("error", (event) => { + console.error("Error:", event.error); + showStatus("An error occurred", "error"); +});