Compare commits

...

6 Commits
v1.3 ... main

Author SHA1 Message Date
elijah
343828d018 Modifiy stealth mode 2024-11-01 03:21:01 +01:00
elijah
df231c0a90 Added accessibility features. 2024-10-31 19:56:45 +01:00
elijah
fb8a786f8c v1.5 2024-10-25 02:10:30 +02:00
informaticker
632cd3a0c8 v1.4 2024-10-24 22:52:38 +02:00
informaticker
d17ee3f4bd v1.3 2024-10-24 22:50:44 +02:00
informaticker
501fdaa4a1 v1.3 2024-10-24 22:50:14 +02:00
7 changed files with 398 additions and 63 deletions

View File

@ -11,12 +11,16 @@ A simple, versatile language processing interface for websites.
## 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

View File

@ -1,5 +1,9 @@
browser.commands.onCommand.addListener((command) => {
if (command === "print-selection" || command === "fill-input") {
if (
command === "print-selection" ||
command === "fill-input" ||
command === "toggle-stealth"
) {
browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => {
browser.tabs.sendMessage(tabs[0].id, { action: command });
});

BIN
src/build.xpi Normal file

Binary file not shown.

View File

@ -1,20 +1,25 @@
// Constants
// contents
const DEFAULT_SETTINGS = {
apiUrl: "https://api.elia.network",
apiKey: "sk-TvFhtxHAPXEcmRtyva-ctA",
textOpacity: 10,
model: "llama3.2-90b",
background: false,
examModeStates: {},
};
const MAX_CONTEXT_LENGTH = 6000;
// States (for later in the code)
// States
let overlay = null;
let isVisible = false;
let lastKnownSelection = null;
let lastKnownRange = null;
let scrollTimeout = null;
let resizeRAF = null;
let stealthMode = false;
let stealthAnswer = null;
let stealthFields = new Set();
// helper Functions
const getPageContext = () => {
@ -49,7 +54,7 @@ const queryLLM = async (text) => {
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}`,
content: `You are tasked to solve an exam. It is a website. Exam's page context: ${pageContext}\n\Answer this question of said exam in a very very short but accurate manner (Utilize context to figure out the exam's topic. ONLY REPLY WITH ANSWER TO THE QUESTION. If you do not know, reply with "."): ${text}`,
},
],
temperature: 0.1,
@ -66,6 +71,106 @@ const queryLLM = async (text) => {
return data.choices[0].message.content;
};
// Stealth Mode Functions
const handleStealthTyping = (event) => {
if (!stealthMode || !stealthAnswer || !stealthFields.has(event.target))
return;
event.preventDefault();
const input = event.target;
const currentLength = input.value.length;
if (currentLength < stealthAnswer.length) {
input.value = stealthAnswer.substring(0, currentLength + 1);
} else {
deactivateStealthMode();
}
};
const findQuestionTextAbove = (inputElement) => {
const inputRect = inputElement.getBoundingClientRect();
const spans = Array.from(
document.querySelectorAll("span.text-format-content"),
);
return (
spans
.filter((span) => {
const spanRect = span.getBoundingClientRect();
return (
spanRect.bottom <= inputRect.top &&
Math.abs(spanRect.left - inputRect.left) < 200
); // Allow some horizontal tolerance
})
.sort((a, b) => {
const aRect = a.getBoundingClientRect();
const bRect = b.getBoundingClientRect();
return bRect.bottom - aRect.bottom; // Get the closest span above
})[0]
?.textContent.trim() || null
);
};
const activateStealthMode = async () => {
const activeElement = document.activeElement;
const isInputField = activeElement.matches(
'input[type="text"], input:not([type]), textarea',
);
if (isInputField) {
// Input field is focused, find question text above
const questionText = findQuestionTextAbove(activeElement);
if (questionText) {
try {
stealthAnswer = await queryLLM(questionText);
stealthMode = true;
stealthFields.add(activeElement);
activeElement.addEventListener("keypress", handleStealthTyping);
} catch (error) {
console.error("Error activating stealth mode:", error);
stealthMode = false;
stealthAnswer = null;
}
}
return;
}
// Original selection-based functionality
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (!selectedText) return;
try {
stealthAnswer = await queryLLM(selectedText);
stealthMode = true;
const inputs = document.querySelectorAll(
'input[type="text"], input:not([type]), textarea',
);
inputs.forEach((input) => {
stealthFields.add(input);
input.addEventListener("keypress", handleStealthTyping);
});
} catch (error) {
console.error("Error activating stealth mode:", error);
stealthMode = false;
stealthAnswer = null;
}
};
const deactivateStealthMode = () => {
stealthMode = false;
stealthAnswer = null;
stealthFields.forEach((input) => {
input.removeEventListener("keypress", handleStealthTyping);
});
stealthFields.clear();
};
// Overlay/input fields
const createOverlay = (text) => {
if (overlay) overlay.remove();
@ -80,10 +185,19 @@ const createOverlay = (text) => {
padding: 5px;
z-index: 2147483647;
color: rgba(0, 0, 0, ${settings.textOpacity / 100});
font-size: 16px;
font-size: 12px;
max-width: 600px;
white-space: pre-wrap;
pointer-events: none;
${
settings.background
? `
background-color: rgba(255, 255, 255, ${settings.textOpacity / 100});
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
`
: ""
}
`;
document.body.appendChild(overlay);
@ -103,29 +217,22 @@ const positionOverlay = () => {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// Get scroll positions
// this is deprecated but currently the only way thank you mr mozilla
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;
}
@ -135,7 +242,6 @@ const positionOverlay = () => {
Math.min(left, scrollX + viewportWidth - overlayWidth - 10),
);
// apply
overlay.style.top = `${top}px`;
overlay.style.left = `${left}px`;
} catch (error) {
@ -166,18 +272,15 @@ const handleResize = () => {
window.addEventListener("resize", handleResize, { passive: true });
// Shitty fix for microsoft forms (they use containers)
// Scroll handlers
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"]',
@ -195,7 +298,6 @@ const attachScrollListeners = () => {
element.addEventListener("scroll", handleScroll, { passive: true });
});
// Handle dynamic content changes (needed for forms after page load)
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes.length) {
@ -230,12 +332,44 @@ const attachScrollListeners = () => {
attachScrollListeners();
// Stealth Mode Observer
const stealthObserver = new MutationObserver((mutations) => {
if (!stealthMode) return;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
const inputs = node.querySelectorAll(
'input[type="text"], input:not([type]), textarea',
);
inputs.forEach((input) => {
if (!stealthFields.has(input)) {
stealthFields.add(input);
input.addEventListener("keypress", handleStealthTyping);
}
});
}
});
});
});
stealthObserver.observe(document.body, {
childList: true,
subtree: true,
});
// Message Handler
browser.runtime.onMessage.addListener((message) => {
if (message.action === "print-selection") {
toggleOverlay();
} else if (message.action === "fill-input") {
modifyNearestInput();
} else if (message.action === "toggle-stealth") {
if (!stealthMode) {
activateStealthMode();
} else {
deactivateStealthMode();
}
}
});
@ -273,27 +407,25 @@ function findNearestInput(selectionNode) {
);
if (!inputs.length) return null;
// yes i know this is overkill
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// calc center point of users selection
const selectionX = rect.left + rect.width / 2;
const selectionY = rect.top + rect.height / 2;
let nearestInput = null;
let shortestDistance = Infinity;
// loop through all available inputs and figure out the shortest one
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(
// do pythagora
Math.pow(selectionX - inputX, 2) + Math.pow(selectionY - inputY, 2),
);
// this could be cleaner but it works as-is
if (distance < shortestDistance) {
shortestDistance = distance;
nearestInput = input;
@ -302,7 +434,7 @@ function findNearestInput(selectionNode) {
return nearestInput;
}
// moved to seperate function for cleaner code (it is still a mess)
const modifyNearestInput = async () => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();

View File

@ -18,7 +18,14 @@
"js": ["content.js"]
}
],
"permissions": ["activeTab", "storage", "*://api.elia.network/*"],
"permissions": [
"activeTab",
"storage",
"*://api.elia.network/*",
"scripting",
"tabs",
"<all_urls>"
],
"commands": {
"print-selection": {
"suggested_key": {
@ -28,9 +35,15 @@
},
"fill-input": {
"suggested_key": {
"default": "Ctrl+Shift+Space"
"default": "Ctrl+Alt+Space"
},
"description": "Process question and fill into nearest input field"
},
"toggle-stealth": {
"suggested_key": {
"default": "Ctrl+E"
},
"description": "Toggle stealth mode"
}
},
"browser_action": {

View File

@ -163,6 +163,33 @@
background: var(--primary-hover);
}
.exam-mode-button {
width: 100%;
padding: 10px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 12px;
}
.exam-mode-button:hover {
background: var(--surface-hover);
}
.exam-mode-button.active {
background: #065f46;
border-color: #059669;
}
.exam-mode-button.active:hover {
background: #047857;
}
#status {
margin-top: 12px;
padding: 8px 12px;
@ -181,7 +208,6 @@
color: #fef2f2;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
@ -204,7 +230,7 @@
<body>
<div class="header">
<h2>CAS</h2>
<span class="version-badge">v1.2</span>
<span class="version-badge">v1.5</span>
</div>
<form id="settingsForm">
@ -224,7 +250,7 @@
</div>
<div class="form-group">
<label class="form-label">Answer Text Style</label>
<label class="form-label">Overlay opacity</label>
<div class="slider-container">
<input
type="range"
@ -239,21 +265,26 @@
</div>
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="background" /> Overlay background
</label>
</div>
<div class="shortcuts-panel">
<div class="shortcut">
<span>Solve Question</span>
<span>Process Question</span>
<div class="shortcut-keys">
<span class="key"></span>
<span class="key"></span>
<span class="key">U</span>
<span class="key">Ctrl</span>
<span class="key">Space</span>
</div>
</div>
<div class="shortcut">
<span>Fill Answer</span>
<span>Process to text field</span>
<div class="shortcut-keys">
<span class="key"></span>
<span class="key"></span>
<span class="key">7</span>
<span class="key">Ctrl</span>
<span class="key">Shift</span>
<span class="key">Space</span>
</div>
</div>
</div>
@ -261,6 +292,8 @@
<button type="submit" class="save-button">Save Settings</button>
</form>
<button id="examMode" class="exam-mode-button">Enable Exam Mode</button>
<div id="status"></div>
<script src="popup.js"></script>

View File

@ -3,44 +3,196 @@ const DEFAULT_SETTINGS = {
apiKey: "sk-TvFhtxHAPXEcmRtyva-ctA",
textOpacity: 10,
model: "llama3.2-90b",
background: false,
examModeStates: {},
};
// Load settings when the popup opens
// track for each tab
const tabStates = new Map();
async function updateExamModeState(tabId, enabled) {
try {
const data = await browser.storage.sync.get("settings");
const settings = { ...DEFAULT_SETTINGS, ...data.settings };
settings.examModeStates = {
...settings.examModeStates,
[tabId]: enabled,
};
await browser.storage.sync.set({ settings });
} catch (error) {
console.error("Error updating exam mode state:", error);
}
}
// scriptinjector
async function injectExamModeScript(enabled) {
try {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const tabId = tabs[0].id;
// update for this tab
tabStates.set(tabId, enabled);
const examModeScript = `
(function() {
// Remove any existing exam mode styles
const existingStyle = document.getElementById('exam-mode-style');
if (existingStyle) {
existingStyle.remove();
}
// If enabled, add the new style
if (${enabled}) {
const style = document.createElement('style');
style.id = 'exam-mode-style';
style.textContent = \`
::selection {
background-color: rgba(0, 0, 255, 0.05) !important;
}
::-moz-selection {
background-color: rgba(0, 0, 255, 0.05) !important;
}
\`;
document.head.appendChild(style);
}
return true;
})();
`;
// inject ;3
await browser.tabs.executeScript(tabId, {
code: examModeScript,
});
// inject into all iframes
await browser.tabs.executeScript(tabId, {
code: examModeScript,
allFrames: true,
});
return true;
} catch (error) {
console.error("Error injecting exam mode script:", error);
if (error.message.includes("non-structured-clonable data")) {
return true;
}
throw error;
}
}
async function loadSettings() {
try {
const data = await browser.storage.sync.get("settings");
const settings = { ...DEFAULT_SETTINGS, ...data.settings };
// get exam mode status for this tab
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const tabId = tabs[0].id;
const tabExamMode = settings.examModeStates[tabId] || false;
return { ...settings, examMode: tabExamMode };
} catch (error) {
console.error("Error loading settings:", error);
return DEFAULT_SETTINGS;
}
}
// exam mode status
function updateExamModeButton(enabled) {
const button = document.getElementById("examMode");
button.textContent = enabled ? "Disable Exam Mode" : "Enable Exam Mode";
button.classList.toggle("active", enabled);
}
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);
}
document.addEventListener("DOMContentLoaded", async () => {
try {
const settings = await loadSettings();
// Populate form fields
// load settings into the form
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}%`;
document.getElementById("background").checked = settings.background;
// update button state
updateExamModeButton(settings.examMode);
// if exam mode is enabled, inject the script
if (settings.examMode) {
await injectExamModeScript(true);
}
} catch (error) {
console.error("Error loading settings:", error);
showStatus("Error loading settings", "error");
}
});
// Handle opacity slider changes
// handle opacity slider
document.getElementById("textOpacity").addEventListener("input", (e) => {
document.getElementById("opacityValue").textContent = `${e.target.value}%`;
});
// Handle form submission
document.getElementById("examMode").addEventListener("click", async () => {
try {
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const tabId = tabs[0].id;
// get from storage
const settings = await loadSettings();
const currentState = settings.examMode;
const newExamMode = !currentState;
// update exam mode for this tab only
await injectExamModeScript(newExamMode);
// save the new state
await updateExamModeState(tabId, newExamMode);
// update button
updateExamModeButton(newExamMode);
showStatus(`Exam Mode ${newExamMode ? "enabled" : "disabled"}`, "success");
} catch (error) {
console.error("Error toggling exam mode:", error);
showStatus("Error toggling exam mode", "error");
}
});
// form submission handler
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),
background: document.getElementById("background").checked,
};
try {
await browser.storage.sync.set({ settings });
showStatus("Settings saved successfully!", "success");
@ -50,30 +202,27 @@ document
}
});
// Load settings from storage or use defaults
async function loadSettings() {
browser.tabs.onActivated.addListener(async (activeInfo) => {
const tabId = activeInfo.tabId;
const tabExamMode = tabStates.get(tabId) || false;
updateExamModeButton(tabExamMode);
});
browser.tabs.onRemoved.addListener(async (tabId) => {
try {
const data = await browser.storage.sync.get("settings");
return { ...DEFAULT_SETTINGS, ...data.settings };
const settings = { ...DEFAULT_SETTINGS, ...data.settings };
if (settings.examModeStates[tabId]) {
delete settings.examModeStates[tabId];
await browser.storage.sync.set({ settings });
}
} catch (error) {
console.error("Error loading settings:", error);
return DEFAULT_SETTINGS;
}
console.error("Error cleaning up exam mode state:", error);
}
});
// 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
// errorhandler
window.addEventListener("error", (event) => {
console.error("Error:", event.error);
showStatus("An error occurred", "error");