This commit is contained in:
elijah 2024-10-24 15:20:04 +02:00
commit ffda90ce16
10 changed files with 753 additions and 0 deletions

22
README.md Normal file
View File

@ -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

8
src/background.js Normal file
View File

@ -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 });
});
}
});

326
src/content.js Normal file
View File

@ -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";
}
};

BIN
src/icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
src/icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

BIN
src/icons/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
src/icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

49
src/manifest.json Normal file
View File

@ -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": ["<all_urls>"],
"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"
}
}

268
src/popup.html Normal file
View File

@ -0,0 +1,268 @@
<!doctype html>
<html>
<head>
<title>CAS</title>
<style>
:root {
--primary: #818cf8;
--primary-hover: #6366f1;
--bg: #0f172a;
--surface: #1e293b;
--surface-hover: #334155;
--border: #334155;
--text: #f8fafc;
--text-secondary: #94a3b8;
--input-bg: #1e293b;
}
body {
width: 320px;
padding: 16px;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.version-badge {
font-size: 12px;
padding: 2px 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-secondary);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.input {
width: 100%;
padding: 8px 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
color: var(--text);
transition: all 0.15s ease;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.1);
}
.slider-container {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: var(--border);
border-radius: 2px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
border: 2px solid var(--surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.slider-value {
font-size: 13px;
color: var(--text-secondary);
min-width: 36px;
text-align: right;
}
.shortcuts-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin: 16px 0;
}
.shortcut {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
}
.shortcut:last-child {
margin-bottom: 0;
}
.shortcut-keys {
display: flex;
gap: 4px;
align-items: center;
}
.key {
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px;
font-size: 11px;
font-family: ui-monospace, monospace;
color: var(--text-secondary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.save-button {
width: 100%;
padding: 10px;
background: var(--primary);
color: var(--text);
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
}
.save-button:hover {
background: var(--primary-hover);
}
#status {
margin-top: 12px;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
display: none;
}
.success {
background: #065f46;
color: #ecfdf5;
}
.error {
background: #991b1b;
color: #fef2f2;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--surface);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--surface-hover);
}
</style>
</head>
<body>
<div class="header">
<h2>CAS</h2>
<span class="version-badge">v1.2</span>
</div>
<form id="settingsForm">
<div class="form-group">
<label class="form-label" for="apiUrl">API URL</label>
<input type="url" id="apiUrl" class="input" required />
</div>
<div class="form-group">
<label class="form-label" for="apiKey">API Key</label>
<input type="password" id="apiKey" class="input" required />
</div>
<div class="form-group">
<label class="form-label" for="model">Model</label>
<input id="model" class="input" required />
</div>
<div class="form-group">
<label class="form-label">Answer Text Style</label>
<div class="slider-container">
<input
type="range"
id="textOpacity"
class="slider"
min="0"
max="100"
step="1"
value="50"
/>
<span id="opacityValue" class="slider-value">50%</span>
</div>
</div>
<div class="shortcuts-panel">
<div class="shortcut">
<span>Solve Question</span>
<div class="shortcut-keys">
<span class="key"></span>
<span class="key"></span>
<span class="key">U</span>
</div>
</div>
<div class="shortcut">
<span>Fill Answer</span>
<div class="shortcut-keys">
<span class="key"></span>
<span class="key"></span>
<span class="key">7</span>
</div>
</div>
</div>
<button type="submit" class="save-button">Save Settings</button>
</form>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>

80
src/popup.js Normal file
View File

@ -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");
});