// ==UserScript==
// @name Canvas ChatGPT BETTER (OpenRouter)
// @namespace http://tampermonkey.net/
// @version 1.8.1_OpenRouter_ModUI
// @description Adds an AI assistant sidepanel using OpenRouter to Instructure Canvas pages.
// @author Original by Riley Campbell, AI modifications, OpenRouter mod, UI Mod by patmarvs
// @match *.instructure.com/*
// @license https://opensource.org/license/bsd-3-clause/
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// ==/UserScript==
(async function() {
'use strict';
function scriptLog(message) {
console.log(`[Canvas AI Sidepanel] ${message}`);
}
scriptLog('Script starting... (OCR.space Edition, ModUI v1.9.1)');
if (window.location.href.includes("conversations")) {
scriptLog('On a "conversations" page, exiting script.');
return;
}
const getStoredValue = async (key, defaultValue) => {
let value = await GM_getValue(key, defaultValue);
if (value === 'true') return true;
if (value === 'false') return false;
return value === undefined ? defaultValue : value;
};
const setStoredValue = async (key, value) => {
await GM_setValue(key, value.toString());
};
let currentAIprompt = await getStoredValue('AIprompt', '');
if (currentAIprompt === null || currentAIprompt === undefined || typeof currentAIprompt !== 'string') {
currentAIprompt = '';
}
window.defaultMessage = { "role": "system", "content": currentAIprompt };
window.AImessages = (currentAIprompt && currentAIprompt.trim() !== "") ? [JSON.parse(JSON.stringify(window.defaultMessage))] : [];
let chatVisible = await getStoredValue('chatVisible', false);
if (typeof chatVisible !== 'boolean') { chatVisible = (chatVisible === 'false'); }
window.hotkeySetting = await getStoredValue('chatToggleHotkey', 'Control+Shift+X');
window.renderMessages = function() {
scriptLog('Rendering messages...');
let container = document.getElementById('ai-assistant-container');
if (!container) {
scriptLog('Error: ai-assistant-container not found for rendering messages.');
return;
}
container.innerHTML = '';
const messagesToRender = window.AImessages.filter(message => {
return !(message.role === "system" && (!message.content || message.content.trim() === ""));
});
for (let i = 0; i < messagesToRender.length; i++) {
let message = messagesToRender[i];
let originalIndex = window.AImessages.findIndex(m => m === message); // Still needed for data-message-id if any other feature uses it
const messageRow = document.createElement('table');
messageRow.style.width = '100%';
const roleText = message.role.charAt(0).toUpperCase() + message.role.slice(1);
const escapedContent = message.content ? message.content.replace(/</g, "<").replace(/>/g, ">") : "";
// MODIFIED: Removed Edit button generation
let headerHTML = `<p class="ai-assistant-role">${roleText}: </p>`;
messageRow.innerHTML = `
<tr>
<th style="text-align: left; vertical-align: top; width: auto;">${headerHTML}</th>
<td data-message-id="${originalIndex}" style="white-space: pre-wrap; word-break: break-word;">${escapedContent.replace(/\n/g, '<br>')}</td>
</tr>
`;
container.appendChild(messageRow);
if (i < messagesToRender.length - 1) {
container.appendChild(document.createElement('hr'));
}
}
// MODIFIED: Removed event listener setup for edit buttons
if (container) {
container.scrollTop = container.scrollHeight;
}
};
window.sendMessage = async function() { /* ... (unchanged: OpenRouter chat logic) ... */
scriptLog('Attempting to send message to OpenRouter...');
const chatContainer = document.getElementById('ai-assistant-container'); // Keep for error display
const myTextArea = document.getElementById('ai-assistant-myTextArea');
const apiKey = await getStoredValue('openaiAPIKey', '');
const aiModel = await getStoredValue('AImodel', 'openai/gpt-3.5-turbo');
if (!apiKey) {
alert('OpenRouter API Key is not set. Please set it in the Settings panel (field is labeled OpenAI API Key).');
scriptLog('API Key missing.');
return;
}
if (!myTextArea || !myTextArea.value.trim()) {
scriptLog('Message input is empty.');
return;
}
const loadingGifId = 'ai-assistant-loading-gif';
const existingLoadingGif = document.getElementById(loadingGifId);
if (chatContainer && !existingLoadingGif) { // chatContainer might be null if UI not fully ready
chatContainer.insertAdjacentHTML('beforeend', `<div id="${loadingGifId}" style="text-align:center;"><img style="display: inline-block; width: 25px; margin: 8px auto;" src="https://i.gifer.com/origin/34/34338d26023e5515f6cc8969aa027bca_w200.gif" alt="Loading..."></div>`);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
let userMessage = { "role": "user", "content": myTextArea.value };
window.AImessages.push(userMessage);
const userMessageContentForRestore = myTextArea.value; // Save for potential retry
myTextArea.value = '';
window.renderMessages();
let messagesForAPI = [];
const memoryEnabled = await getStoredValue('AImemory', false);
window.defaultMessage.content = document.getElementById('ai-assistant-systemPrompt').value;
if (memoryEnabled) {
messagesForAPI = JSON.parse(JSON.stringify(window.AImessages));
if (window.defaultMessage.content && window.defaultMessage.content.trim() !== "") {
const systemMsgIndex = messagesForAPI.findIndex(m => m.role === 'system');
if (systemMsgIndex > -1) {
messagesForAPI[systemMsgIndex].content = window.defaultMessage.content;
} else {
messagesForAPI.unshift(JSON.parse(JSON.stringify(window.defaultMessage)));
}
} else {
messagesForAPI = messagesForAPI.filter(m => m.role !== 'system');
}
} else {
if (window.defaultMessage.content && window.defaultMessage.content.trim() !== "") {
messagesForAPI.push(JSON.parse(JSON.stringify(window.defaultMessage)));
}
messagesForAPI.push(userMessage);
}
messagesForAPI = messagesForAPI.filter(m => m.content && m.content.trim() !== "");
if (messagesForAPI.length === 0) {
scriptLog('No messages to send to API after filtering.');
document.getElementById(loadingGifId)?.remove();
window.AImessages.push({role: "assistant", content: "Internal error: No content to send."});
window.renderMessages();
return;
}
scriptLog(`Sending to OpenRouter API with model ${aiModel}. Memory: ${memoryEnabled}. Messages count: ${messagesForAPI.length}.`);
GM_xmlhttpRequest({
method: "POST",
url: 'https://openrouter.ai/api/v1/chat/completions',
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
data: JSON.stringify({ "model": aiModel, "messages": messagesForAPI, "temperature": 1.0 }),
onload: function(response) {
document.getElementById(loadingGifId)?.remove();
try {
if (response.status >= 200 && response.status < 300) {
let result = JSON.parse(response.responseText);
if (result.choices && result.choices.length > 0 && result.choices[0].message) {
window.AImessages.push(result.choices[0].message);
} else {
if (result.error && result.error.message) {
throw new Error(`API Error: ${result.error.message} (Type: ${result.error.type}, Code: ${result.error.code || 'N/A'})`);
}
throw new Error("Invalid response structure from API.");
}
} else {
let errorInfo = `API Error ${response.status}: ${response.statusText}`;
try {
const errData = JSON.parse(response.responseText);
if (errData.error && errData.error.message) {
errorInfo += ` - ${errData.error.message}`;
if(errData.error.type) errorInfo += ` (Type: ${errData.error.type})`;
if(errData.error.code) errorInfo += ` (Code: ${errData.error.code})`;
}
} catch (e) { /* Stick with statusText */ }
throw new Error(errorInfo);
}
} catch (e) {
scriptLog(`Error processing response: ${e.message}`);
const errorP = document.createElement('p');
errorP.style.color = 'red';
errorP.style.padding = '5px';
errorP.innerHTML = `<strong>Error:</strong> ${e.message.replace(/</g, "<").replace(/>/g, ">")} <button class="ai-assistant-retry-button">Retry</button>`;
const currentChatContainer = document.getElementById('ai-assistant-container');
if(currentChatContainer) {
currentChatContainer.appendChild(errorP);
currentChatContainer.scrollTop = currentChatContainer.scrollHeight;
}
errorP.querySelector('.ai-assistant-retry-button')?.addEventListener('click', () => {
const currentMyTextArea = document.getElementById('ai-assistant-myTextArea');
currentMyTextArea.value = userMessageContentForRestore;
errorP.remove();
scriptLog("Retry button clicked.");
currentMyTextArea.focus();
});
} finally {
if (! (document.querySelector('#ai-assistant-container p[style*="color: red;"]')) ) {
window.renderMessages();
}
const currentChatContainer = document.getElementById('ai-assistant-container');
if (currentChatContainer) currentChatContainer.scrollTop = currentChatContainer.scrollHeight;
}
},
onerror: function(response) {
document.getElementById(loadingGifId)?.remove();
const errorText = `Network Error: ${response.statusText || 'Could not connect'}.`;
scriptLog(`Request Error: ${errorText}`);
const errorP = document.createElement('p');
errorP.style.color = 'red';
errorP.style.padding = '5px';
errorP.innerHTML = `<strong>${errorText}</strong>`;
const currentChatContainer = document.getElementById('ai-assistant-container');
if(currentChatContainer) {
currentChatContainer.appendChild(errorP);
currentChatContainer.scrollTop = currentChatContainer.scrollHeight;
}
}
});
};
window.populateModels = async function() { /* ... (unchanged: OpenRouter model population) ... */
scriptLog('Populating models from OpenRouter...');
const modelsSelect = document.getElementById('ai-assistant-models');
const apiKey = await getStoredValue('openaiAPIKey', '');
if (!apiKey) {
scriptLog('API Key missing for populating models.');
if (modelsSelect) modelsSelect.innerHTML = '<option value="">Set API Key (labeled OpenAI API Key) to load models</option>';
return;
}
if (!modelsSelect) { scriptLog('Models select element not found.'); return; }
modelsSelect.innerHTML = '<option value="">Loading models from OpenRouter...</option>';
const commonModels = [
'openai/gpt-4o', 'openai/gpt-4-turbo', 'anthropic/claude-3-opus', 'anthropic/claude-3-sonnet',
'anthropic/claude-3-haiku', 'google/gemini-pro-1.5', 'google/gemini-flash-1.5', 'mistralai/mistral-large',
];
GM_xmlhttpRequest({
method: "GET",
url: 'https://openrouter.ai/api/v1/models',
headers: { "Authorization": `Bearer ${apiKey}` },
onload: async function(response) {
try {
modelsSelect.innerHTML = '';
if (response.status >= 200 && response.status < 300) {
const options = JSON.parse(response.responseText);
let availableModels = [];
if (options.data) {
options.data.forEach(item => { availableModels.push(item.id); });
}
availableModels.sort((a, b) => a.localeCompare(b));
let effectiveCommonModels = commonModels.filter(m => availableModels.includes(m));
let finalModelList = [...new Set([...effectiveCommonModels, ...availableModels])];
if (finalModelList.length === 0) {
modelsSelect.innerHTML = '<option value="">No models found on OpenRouter.</option>';
} else {
finalModelList.forEach(modelId => {
let element = document.createElement("option");
element.value = modelId;
element.innerHTML = modelId;
modelsSelect.appendChild(element);
});
const storedModel = await getStoredValue('AImodel', 'openai/gpt-3.5-turbo');
if (finalModelList.includes(storedModel)) {
modelsSelect.value = storedModel;
} else if (finalModelList.length > 0) {
modelsSelect.value = finalModelList[0];
await setStoredValue('AImodel', finalModelList[0]);
}
}
} else {
let errorDetail = `Failed: ${response.status} ${response.statusText}`;
try {
const errData = JSON.parse(response.responseText);
if (errData.error && errData.error.message) {
errorDetail = `API Error: ${errData.error.message.substring(0,50)}...`;
}
} catch (e) { /* ignore */ }
modelsSelect.innerHTML = `<option value="">${errorDetail}</option>`;
scriptLog(`Failed to fetch models: ${response.status} ${response.responseText}`);
}
} catch (e) {
scriptLog(`Error parsing models response: ${e.message}`);
modelsSelect.innerHTML = '<option value="">Error parsing models data</option>';
}
},
onerror: function() {
scriptLog('Network error fetching models.');
if (modelsSelect) modelsSelect.innerHTML = '<option value="">Network error</option>';
}
});
};
async function handleOCRSnip() { /* ... (unchanged: OCR.space logic) ... */
scriptLog("OCR Snip initiated (OCR.space).");
const myTextArea = document.getElementById('ai-assistant-myTextArea');
if (!myTextArea) return;
const ocrApiKey = await getStoredValue('ocrSpaceApiKey', '');
if (!ocrApiKey) {
showTemporaryNotification("OCR.space API Key not set in Settings.", "error", 5000);
alert("Please set your OCR.space API Key in the AI Sidepanel settings to use the OCR feature. A free key 'helloworld' can be used for limited testing.");
return;
}
let originalText = myTextArea.value;
myTextArea.value = "Preparing OCR... Select screen area to capture...";
myTextArea.disabled = true;
showTemporaryNotification("Select screen area for OCR...", "info", 5000);
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: { mediaSource: "screen", cursor: "always" }, audio: false });
const videoEl = document.createElement('video');
videoEl.style.display = 'none';
document.body.appendChild(videoEl);
await new Promise((resolvePlay, rejectPlay) => {
videoEl.onloadedmetadata = () => {
videoEl.play().then(resolvePlay).catch(err => {
scriptLog("Video play error: " + err);
rejectPlay(new Error("Could not play screen capture stream."));
});
};
videoEl.srcObject = stream;
});
if (!videoEl.videoWidth || !videoEl.videoHeight) {
await new Promise(r => setTimeout(r, 200));
if (!videoEl.videoWidth || !videoEl.videoHeight) {
stream.getTracks().forEach(track => track.stop());
videoEl.remove();
throw new Error("Could not get screen capture dimensions after delay.");
}
}
const canvas = document.createElement('canvas');
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
stream.getTracks().forEach(track => track.stop());
videoEl.remove();
const base64Image = canvas.toDataURL('image/png');
myTextArea.value = "Processing OCR with OCR.space...";
showTemporaryNotification("Sending image to OCR.space...", "info", 10000);
GM_xmlhttpRequest({
method: "POST",
url: "https://api.ocr.space/parse/image",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: `apikey=${encodeURIComponent(ocrApiKey)}&base64Image=${encodeURIComponent(base64Image)}&language=eng&isOverlayRequired=false&detectOrientation=true`,
onload: function(response) {
myTextArea.disabled = false;
myTextArea.focus();
try {
const result = JSON.parse(response.responseText);
if (result.IsErroredOnProcessing) {
scriptLog(`OCR.space Error: ${result.ErrorMessage.join(", ")}`);
showTemporaryNotification(`OCR.space Error: ${result.ErrorMessage[0]}`, "error", 7000);
myTextArea.value = originalText;
} else if (result.ParsedResults && result.ParsedResults.length > 0) {
const parsedText = result.ParsedResults[0].ParsedText.trim();
if (parsedText) {
myTextArea.value = originalText + (originalText && parsedText ? "\n" : "") + parsedText;
showTemporaryNotification("OCR complete! Text added.", "success");
} else {
showTemporaryNotification("OCR complete, but no text found.", "info");
myTextArea.value = originalText;
}
} else {
showTemporaryNotification("OCR completed, but no results returned.", "info");
myTextArea.value = originalText;
}
} catch (e) {
scriptLog("Error parsing OCR.space response: " + e);
showTemporaryNotification("Failed to parse OCR response.", "error");
myTextArea.value = originalText;
}
},
onerror: function(responseDetails) {
scriptLog("OCR.space request failed: " + responseDetails.statusText);
myTextArea.disabled = false;
myTextArea.focus();
myTextArea.value = originalText;
showTemporaryNotification("OCR request failed. Check network/console.", "error");
}
});
} catch (err) {
scriptLog(`OCR Snip Error: ${err.message || err}`);
myTextArea.disabled = false;
myTextArea.focus();
myTextArea.value = originalText;
if (err.name === "NotAllowedError" || (err.message && err.message.toLowerCase().includes("permission denied"))) {
showTemporaryNotification("Screen capture permission denied.", "error");
} else {
showTemporaryNotification(`OCR setup failed: ${err.message.substring(0, 50)}...`, "error", 5000);
}
}
}
function showTemporaryNotification(message, type = "info", duration = 3000) { /* ... (unchanged) ... */
const notificationArea = document.getElementById('ai-assistant-notification-area');
if (!notificationArea) {
scriptLog("Notification area not found, logging: " + message);
return;
}
const existingSameType = notificationArea.querySelector(`.ai-assistant-notification.${type}`);
if(existingSameType && existingSameType.textContent.startsWith(message.substring(0,10))) {
existingSameType.remove();
}
const notification = document.createElement('div');
notification.className = `ai-assistant-notification ${type}`;
notification.textContent = message;
notificationArea.appendChild(notification);
setTimeout(() => {
notification.remove();
}, duration);
}
function setupUI() {
scriptLog('Setting up UI...');
const accentGrey = '#6A737C';
const accentGreyHover = '#525960';
const lightGreyBg = '#f0f0f0';
const lighterGreyBg = '#f8f9fa';
const focusRingColor = 'rgba(106, 115, 124, .5)';
const css = `
#ai-assistant-box { position: fixed; bottom: 15px; right: 15px; width: 450px; height: 30vh; background-color: #fff; box-shadow: 0 5px 20px rgba(0,0,0,0.25); border-radius: 16px; display: flex; flex-direction: column; z-index: 10001; border: 1px solid #ccc; font-family: Arial, sans-serif; font-size: 14px; transform: translateY(0); opacity: 1; transition: transform 0.3s ease-out, opacity 0.3s ease-out; overflow: hidden; }
#ai-assistant-box.hidden { transform: translateY(calc(100% + 30px)); opacity: 0; pointer-events: none; }
#ai-assistant-notification-area { position: absolute; top: 0; left: 0; right: 0; z-index: 10002; padding-top: 10px; pointer-events: none; }
.ai-assistant-notification { padding: 8px 15px; margin: 0 15px 5px 15px; border-radius: 4px; color: white; text-align: center; font-size: 0.9em; box-shadow: 0 2px 4px rgba(0,0,0,0.1); opacity: 0.95; pointer-events: auto; }
.ai-assistant-notification.success { background-color: #28a745; }
.ai-assistant-notification.error { background-color: #dc3545; }
.ai-assistant-notification.info { background-color: ${accentGrey}; }
#ai-assistant-box .ai-assistant-main-content { padding: 10px 15px 15px 15px; display: flex; flex-direction: column; flex-grow:1; overflow:hidden; background: #fdfdfd; position:relative; }
#ai-assistant-container { flex-grow: 1; overflow-y: auto; padding: 10px; border-bottom: 1px solid #eee; margin-bottom: 10px; min-height: 50px; background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; scroll-behavior: smooth;}
#ai-assistant-container table { width: 100%; margin-bottom: 8px; border-collapse: collapse; }
#ai-assistant-container th { font-weight: bold; text-align: left; vertical-align:top; padding: 4px 8px 4px 2px; color: #333; }
#ai-assistant-container td { padding: 4px 2px 4px 8px; color: #555; }
#ai-assistant-container hr { border: 0; border-top: 1px solid #f0f0f0; margin: 8px 0; }
#ai-assistant-myTextArea { display: block; width: calc(100% - 22px); min-height:40px; max-height: 100px; resize: vertical; margin: 0 auto 10px auto; padding: 10px; border: 1px solid #ccc; border-radius: 6px; font-size:1em; }
#ai-assistant-myTextArea:focus { border-color: ${accentGrey}; box-shadow: 0 0 0 0.2rem ${focusRingColor}; }
#ai-assistant-box .ai-assistant-button { background-color: ${accentGrey}; color: white; border: none; padding: 8px 10px; margin-bottom: 8px; /* Added margin for buttons in settings */ border-radius: 5px; cursor: pointer; text-align: center; font-size:0.9em; /* Restored font size */ transition: background-color 0.2s ease; display: block; width: 100%; box-sizing: border-box; }
#ai-assistant-box .ai-assistant-button:hover { background-color: ${accentGreyHover}; }
/* #ai-assistant-button-bar REMOVED */
.ai-assistant-settings-toggle { display: block; font-weight: bold; font-size: 1em; text-align: center; padding: 10px; color: ${accentGreyHover}; background: ${lightGreyBg}; cursor: pointer; border-top: 1px solid #ddd; transition: background-color 0.2s ease-out; margin:0; border-radius: 0 0 15px 15px; }
.ai-assistant-settings-toggle:hover { background-color: #e2e6ea; }
.ai-assistant-collapsible-content { max-height: 0px; overflow-y: auto; transition: max-height .35s ease-in-out; background: ${lighterGreyBg}; border-top:1px solid #ddd;}
.ai-assistant-collapsible-content .content-inner { padding: 15px; border-bottom-left-radius: 15px; border-bottom-right-radius: 15px; }
#ai-assistant-settings-checkbox:checked ~ .ai-assistant-collapsible-content { max-height: 500px; /* Increased max-height for settings */ }
#ai-assistant-settings-checkbox { display: none; }
.ai-assistant-switch-container { display: flex; align-items: center; margin-bottom:12px; }
.ai-assistant-switch { position: relative; display: inline-block; width: 44px; height: 24px; margin-right: 10px; }
.ai-assistant-switch input { opacity: 0; width: 0; height: 0; }
.ai-assistant-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px; }
.ai-assistant-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .ai-assistant-slider { background-color: ${accentGrey}; }
input:checked + .ai-assistant-slider:before { transform: translateX(20px); }
#ai-assistant-systemPrompt { display: block; width: calc(100% - 22px); min-height: 50px; max-height: 100px; resize: vertical; margin: 5px auto 10px auto; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size:0.9em; }
#ai-assistant-apiKey, #ai-assistant-ocrApiKey { display: inline-block; width: calc(60% - 10px); margin-right:5px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size:0.9em; box-sizing: border-box; }
#ai-assistant-setAPIKeyButton, #ai-assistant-setOcrApiKeyButton { display: inline-block; width: calc(40% - 10px); padding: 8px 0px; box-sizing: border-box;}
/* Removed Edit Button CSS */
.ai-assistant-role { display: inline; margin-right: 5px; font-weight:bold;}
#ai-assistant-models { margin-bottom:10px; display:block; width:100%; padding:8px; border-radius:4px; border:1px solid #ccc; font-size:0.9em; box-sizing: border-box;}
.ai-assistant-label { display: block; margin-bottom: 5px; font-weight: bold; font-size:0.9em; }
.ai-assistant-retry-button { background-color: ${accentGrey}; color: white; border: none; padding: 3px 8px; border-radius: 3px; cursor: pointer; font-size: 0.9em; margin-left: 10px;}
.ai-assistant-retry-button:hover { background-color: ${accentGreyHover};}
#ai-assistant-hotkey-input-container { margin-top: 10px; margin-bottom: 5px; }
#ai-assistant-hotkey-input { width: calc(60% - 10px); margin-right:5px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size:0.9em; box-sizing: border-box;}
#ai-assistant-setHotkeyButton {display: inline-block; width: calc(40% - 10px); padding: 8px 0px; box-sizing: border-box;}
#ai-assistant-toggle-button { position: fixed; bottom: 20px; right: 20px; z-index: 10000; background-color: ${accentGrey}; color: white; border:none; border-radius: 50%; width: 50px; height: 50px; font-size: 24px; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.2); display: flex; align-items: center; justify-content: center; transition: background-color 0.2s ease, transform 0.2s ease; }
#ai-assistant-toggle-button:hover { background-color: ${accentGreyHover}; transform: scale(1.1); }
#ai-assistant-toggle-button.hidden { display: none !important; }
.api-key-group { margin-bottom: 10px; }
.settings-action-buttons-group { margin-top: 15px; border-top: 1px solid #ddd; padding-top: 15px; } /* Group for action buttons in settings */
`;
GM_addStyle(css);
const chatToggleBtn = document.createElement('button');
chatToggleBtn.id = 'ai-assistant-toggle-button';
chatToggleBtn.innerHTML = '💬';
chatToggleBtn.title = `Toggle AI Assistant (Hotkey: ${window.hotkeySetting})`;
if (!chatVisible) {
chatToggleBtn.classList.add('hidden');
}
document.body.appendChild(chatToggleBtn);
const elem = document.createElement('div');
elem.id = 'ai-assistant-box';
if (!chatVisible) {
elem.classList.add('hidden');
} else {
elem.classList.remove('hidden');
chatToggleBtn.innerHTML = '✕';
}
// MODIFIED: HTML structure for main content and settings
elem.innerHTML = `
<div class="ai-assistant-main-content">
<div id="ai-assistant-notification-area"></div>
<div id="ai-assistant-container"></div>
<textarea id="ai-assistant-myTextArea" placeholder="Ask AI Assistant..."></textarea>
</div>
<input id="ai-assistant-settings-checkbox" type="checkbox">
<label for="ai-assistant-settings-checkbox" class="ai-assistant-settings-toggle">Settings <span class="settings-arrow">▼</span></label>
<div class="ai-assistant-collapsible-content">
<div class="content-inner">
<div class="ai-assistant-switch-container">
<label class="ai-assistant-switch">
<input id="ai-assistant-memory" type="checkbox">
<span class="ai-assistant-slider"></span>
</label>
<label for="ai-assistant-memory" style="font-size:0.9em;">Enable chat memory</label>
</div>
<label for="ai-assistant-models" class="ai-assistant-label">Chat Model (OpenRouter):</label>
<select name="models" id="ai-assistant-models"></select>
<div class="api-key-group">
<label for="ai-assistant-apiKey" class="ai-assistant-label">OpenRouter API Key:</label>
<div>
<input id="ai-assistant-apiKey" placeholder="OpenRouter Key (sk-or-...)" type="password">
<button class="ai-assistant-button" id="ai-assistant-setAPIKeyButton">Set Chat Key</button>
</div>
</div>
<div class="api-key-group">
<label for="ai-assistant-ocrApiKey" class="ai-assistant-label">OCR.space API Key:</label>
<div>
<input id="ai-assistant-ocrApiKey" placeholder="OCR.space Key" type="password">
<button class="ai-assistant-button" id="ai-assistant-setOcrApiKeyButton">Set OCR Key</button>
</div>
</div>
<label for="ai-assistant-systemPrompt" class="ai-assistant-label">System Prompt (Instructions for AI):</label>
<textarea id="ai-assistant-systemPrompt" placeholder="e.g., Act as a helpful teaching assistant."></textarea>
<div id="ai-assistant-hotkey-input-container">
<label for="ai-assistant-hotkey-input" class="ai-assistant-label">Toggle Hotkey (e.g. Control+Shift+X):</label>
<div>
<input id="ai-assistant-hotkey-input" type="text" placeholder="Example: Control+Shift+M">
<button class="ai-assistant-button" id="ai-assistant-setHotkeyButton">Set Hotkey</button>
</div>
</div>
<div class="settings-action-buttons-group"> <button class="ai-assistant-button" id="ai-assistant-ocrButton" title="Capture screen area for OCR">OCR Screen</button>
<button class="ai-assistant-button" id="ai-assistant-clearHistoryButton">Clear Chat & Apply Prompt</button>
</div>
</div>
</div>
`;
document.body.appendChild(elem);
scriptLog('UI elements injected.');
const settingsCheckbox = document.getElementById('ai-assistant-settings-checkbox');
const settingsArrow = elem.querySelector('.settings-arrow');
settingsCheckbox.addEventListener('change', () => {
if (settingsArrow) settingsArrow.innerHTML = settingsCheckbox.checked ? '▲' : '▼';
});
function toggleChatVisibility(show) { /* ... (unchanged) ... */
const chatBox = document.getElementById('ai-assistant-box');
const floatingToggleButton = document.getElementById('ai-assistant-toggle-button');
if (typeof show === 'boolean') {
chatVisible = show;
} else {
chatVisible = !chatVisible;
}
setStoredValue('chatVisible', chatVisible);
if (chatVisible) {
chatBox.classList.remove('hidden');
if (floatingToggleButton) floatingToggleButton.classList.remove('hidden');
if (floatingToggleButton) floatingToggleButton.innerHTML = '✕';
document.getElementById('ai-assistant-myTextArea')?.focus();
} else {
chatBox.classList.add('hidden');
if (floatingToggleButton ) {
floatingToggleButton.innerHTML = '💬';
}
}
}
window.toggleChatVisibility = toggleChatVisibility;
chatToggleBtn.addEventListener('click', () => toggleChatVisibility());
// Removed listener for header 'X' button as header is gone
(async () => { /* ... (Event listeners setup, send button listener removed) ... */
document.getElementById('ai-assistant-systemPrompt').value = await getStoredValue('AIprompt', '');
document.getElementById('ai-assistant-memory').checked = await getStoredValue('AImemory', false);
const currentHotkeyDisplay = await getStoredValue('chatToggleHotkey', 'Control+Shift+X');
document.getElementById('ai-assistant-hotkey-input').value = currentHotkeyDisplay;
const updateButtonTitles = (hotkey) => {
const ftb = document.getElementById('ai-assistant-toggle-button');
if (ftb) { ftb.title = `Toggle AI Assistant (Hotkey: ${hotkey})`;}
};
updateButtonTitles(currentHotkeyDisplay);
await window.populateModels();
const storedModel = await getStoredValue('AImodel', 'openai/gpt-3.5-turbo');
const modelSelect = document.getElementById('ai-assistant-models');
if (modelSelect && modelSelect.options.length > 0) {
if (Array.from(modelSelect.options).some(opt => opt.value === storedModel)) {
modelSelect.value = storedModel;
} else if (modelSelect.options[0] && modelSelect.options[0].value) {
modelSelect.value = modelSelect.options[0].value;
await setStoredValue('AImodel', modelSelect.options[0].value);
}
}
document.getElementById('ai-assistant-myTextArea').addEventListener("keydown", async function(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
await window.sendMessage();
}
});
// MODIFIED: Send button listener removed
document.getElementById('ai-assistant-ocrButton').addEventListener('click', handleOCRSnip);
document.getElementById('ai-assistant-clearHistoryButton').addEventListener('click', async () => {
scriptLog('Clear history clicked.');
const newSystemPromptText = document.getElementById('ai-assistant-systemPrompt').value;
window.defaultMessage.content = newSystemPromptText;
await setStoredValue('AIprompt', newSystemPromptText);
if (newSystemPromptText && newSystemPromptText.trim() !== "") {
window.AImessages = [JSON.parse(JSON.stringify(window.defaultMessage))];
} else {
window.AImessages = [];
}
window.renderMessages();
scriptLog('History cleared.');
});
document.getElementById('ai-assistant-setAPIKeyButton').onclick = async () => {
const apiKeyInput = document.getElementById('ai-assistant-apiKey');
if (apiKeyInput && apiKeyInput.value.trim()) {
await setStoredValue('openaiAPIKey', apiKeyInput.value.trim());
apiKeyInput.value = '';
scriptLog('OpenRouter API Key set.');
alert('OpenRouter API Key saved! Model list will refresh.');
await window.populateModels();
} else {
alert('Please enter a valid OpenRouter API key.');
}
};
document.getElementById('ai-assistant-setOcrApiKeyButton').onclick = async () => {
const ocrApiKeyInput = document.getElementById('ai-assistant-ocrApiKey');
if (ocrApiKeyInput && ocrApiKeyInput.value.trim()) {
await setStoredValue('ocrSpaceApiKey', ocrApiKeyInput.value.trim());
ocrApiKeyInput.value = '';
scriptLog('OCR.space API Key set.');
alert('OCR.space API Key saved!');
} else {
alert('Please enter a valid OCR.space API key.');
}
};
document.getElementById('ai-assistant-setHotkeyButton').onclick = async () => {
const hotkeyInput = document.getElementById('ai-assistant-hotkey-input');
const newHotkey = hotkeyInput.value.trim();
if (newHotkey) {
if (newHotkey.split('+').length > 0) {
await setStoredValue('chatToggleHotkey', newHotkey);
window.hotkeySetting = newHotkey;
updateButtonTitles(newHotkey);
alert(`Hotkey set to: ${newHotkey}.`);
scriptLog(`Hotkey updated: ${newHotkey}`);
} else {
alert('Invalid hotkey format.');
}
} else {
alert('Please enter a hotkey.');
}
};
let settingsSaveTimeout;
const scheduleSaveSettings = async (eventSourceId = null) => {
clearTimeout(settingsSaveTimeout);
settingsSaveTimeout = setTimeout(async () => {
await setStoredValue('AImemory', document.getElementById('ai-assistant-memory').checked);
const modelSel = document.getElementById('ai-assistant-models');
if (modelSel && modelSel.value) await setStoredValue('AImodel', modelSel.value);
const systemPromptText = document.getElementById('ai-assistant-systemPrompt').value;
if (eventSourceId === 'ai-assistant-systemPrompt' || eventSourceId === null) {
await setStoredValue('AIprompt', systemPromptText);
window.defaultMessage.content = systemPromptText;
const systemMsgIndex = window.AImessages.findIndex(m => m.role === 'system');
if (systemPromptText && systemPromptText.trim() !== "") {
if (systemMsgIndex > -1) window.AImessages[systemMsgIndex].content = systemPromptText;
else window.AImessages.unshift(JSON.parse(JSON.stringify(window.defaultMessage)));
} else {
if (systemMsgIndex > -1) window.AImessages.splice(systemMsgIndex, 1);
}
if (eventSourceId === 'ai-assistant-systemPrompt') window.renderMessages();
}
scriptLog('Settings auto-saved (excluding API keys set by button).');
}, 1000);
};
document.getElementById('ai-assistant-memory').addEventListener('change', () => scheduleSaveSettings('ai-assistant-memory'));
document.getElementById('ai-assistant-models').addEventListener('change', () => scheduleSaveSettings('ai-assistant-models'));
document.getElementById('ai-assistant-systemPrompt').addEventListener('input', () => scheduleSaveSettings('ai-assistant-systemPrompt'));
document.addEventListener('keydown', (e) => { /* ... (unchanged) ... */
if (!window.hotkeySetting || typeof window.hotkeySetting !== 'string') return;
const keys = window.hotkeySetting.toUpperCase().split('+');
const mainKey = keys.pop();
let ctrl = keys.includes('CONTROL') || keys.includes('CTRL');
let shift = keys.includes('SHIFT');
let alt = keys.includes('ALT');
let meta = keys.includes('META') || keys.includes('COMMAND');
if ((ctrl === e.ctrlKey) && (shift === e.shiftKey) && (alt === e.altKey) && (meta === e.metaKey) && (e.key.toUpperCase() === mainKey)) {
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable) && activeEl.id !== 'ai-assistant-myTextArea') {
if (['ai-assistant-systemPrompt', 'ai-assistant-apiKey', 'ai-assistant-ocrApiKey', 'ai-assistant-hotkey-input'].includes(activeEl.id)) return;
}
e.preventDefault();
e.stopPropagation();
toggleChatVisibility();
scriptLog(`Hotkey "${window.hotkeySetting}" pressed.`);
}
});
if (chatVisible) {
window.renderMessages();
}
scriptLog('Initial render logic applied. Event listeners attached.');
})();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupUI);
} else {
setupUI();
}
scriptLog('Script initialization phase complete.');
})();