Noordhoff AI

Ctrl+Shift+A voor antwoord. Ctrl+Shift+G als de docent langskomt.

// ==UserScript==
// @name         Noordhoff AI
// @version      1.0
// @description  Ctrl+Shift+A voor antwoord. Ctrl+Shift+G als de docent langskomt.
// @author       incomplete_tree
// @match        https://*.noordhoff.nl/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @license      GPL v3.0+
// @namespace https://gf.qytechs.cn/users/1511158
// ==/UserScript==

(function() {
    'use strict';

    const OPENROUTER_API_KEY_GM_ID = 'openrouter_api_key';
    const CONCISENESS_LEVEL_GM_ID = 'ai_conciseness_level';

    const concisenessLevels = {
        '-2': 'extremely concise. Use only essential keywords or numbers.',
        '-1': 'very concise. Be as brief as possible.',
        '0': 'default. Provide a clear and standard-length answer.',
        '1': 'a bit more detailed. Explain the steps briefly.',
        '2': 'very detailed. Provide a full explanation as if teaching a student.'
    };
    let currentConciseness = '0'; // Default

    // --- API Key Management ---
    async function getApiKey() {
        let apiKey = await GM_getValue(OPENROUTER_API_KEY_GM_ID);
        if (!apiKey || apiKey.trim() === "") {
            apiKey = window.prompt('Please enter your OpenRouter API key:');
            if (apiKey && apiKey.trim() !== "") {
                await GM_setValue(OPENROUTER_API_KEY_GM_ID, apiKey);
            } else {
                return null;
            }
        }
        return apiKey;
    }

    // --- Main execution starts here ---
    (async function() {
        const apiKey = await getApiKey();
        if (!apiKey) {
            alert('OpenRouter API key is not set. The hotkeys will not work.');
            return;
        }

        currentConciseness = await GM_getValue(CONCISENESS_LEVEL_GM_ID, '0');
        console.log(`AI Answer Assistant: Key is set. Initial conciseness: ${concisenessLevels[currentConciseness]}.`);
        console.log("Hotkeys: Ctrl+Shift+A (Show Answer), Ctrl+Shift+G (Hide All).");

        document.addEventListener('keydown', (event) => {
            if (event.ctrlKey && event.shiftKey && (event.key === 'A' || event.key === 'a')) {
                event.preventDefault();
                handleGenerateHotkey(apiKey);
            }
            if (event.ctrlKey && event.shiftKey && (event.key === 'G' || event.key === 'g')) {
                event.preventDefault();
                hideAllAnswers();
            }
        });
    })();

    // --- Helper to find the current question block ---
    function findCurrentQuestionBlock() {
        const focusedElement = document.activeElement;
        if (focusedElement && focusedElement !== document.body) {
            const block = focusedElement.closest('.pl-particle');
            if (block) return block;
        }
        for (const block of document.querySelectorAll('.pl-particle')) {
            const rect = block.getBoundingClientRect();
            if (rect.top >= 0 && rect.top <= window.innerHeight * 0.8) {
                return block;
            }
        }
        return null;
    }

    // --- Main Hotkey Logic ---
    async function handleGenerateHotkey(apiKey, newConciseness = null) {
        if (newConciseness !== null) {
            currentConciseness = newConciseness;
            await GM_setValue(CONCISENESS_LEVEL_GM_ID, currentConciseness);
            console.log(`Conciseness updated to: ${concisenessLevels[currentConciseness]}`);
        }

        const questionBlock = findCurrentQuestionBlock();
        if (!questionBlock) {
            alert('Could not find a focused or visible question. Please scroll to a question and try again.');
            return;
        }

        displayAnswer(questionBlock, '🤖 Analyzing question...', true);

        try {
            const questionHeader = questionBlock.querySelector('h5');
            const questionElement = questionBlock.querySelector('.pl-content-question');
            if (!questionElement) throw new Error('Could not find question content element.');

            const headerText = questionHeader ? `Sub-question: ${questionHeader.innerText}\n\n` : '';
            const questionHtml = questionElement.innerHTML;
            const concisenessInstruction = concisenessLevels[currentConciseness];

            // --- NEW: Image Processing Step ---
            const images = questionElement.querySelectorAll('img');
            let imageDescriptions = '';

            if (images.length > 0) {
                displayAnswer(questionBlock, `🤖 Found ${images.length} image(s), analyzing...`, true);
                const descriptionPromises = Array.from(images).map(img => describeImageWithAI(img.src, apiKey));
                const descriptions = await Promise.all(descriptionPromises);
                imageDescriptions = "\n\n## Image Descriptions:\n" + descriptions.map((desc, i) => `[Image ${i+1}]: ${desc}`).join("\n");
                displayAnswer(questionBlock, '🤖 Generating answer...', true);
            }
            // --- End of Image Processing ---

            const prompt = `You are an expert teaching assistant. A student has asked for help with the following question. Your response should be ${concisenessInstruction}. Analyze the question's HTML content and any provided image descriptions. Provide the answer. Do not include any extra conversational text like "Here is the answer:". Just provide the answer itself.\n\n${headerText}## Question HTML:\n\`\`\`html\n${questionHtml}\n\`\`\`${imageDescriptions}`;

            const response = await callOpenRouter(prompt, apiKey, 'google/gemini-flash-1.5');
            const answer = response.choices[0].message.content.trim();

            displayAnswer(questionBlock, answer, false, apiKey);

        } catch (error) {
            console.error('AI Assistant Error:', error);
            displayAnswer(questionBlock, `Error: ${error.message}`);
        }
    }

    // --- Universal API Call Function ---
    function callOpenRouter(prompt, apiKey, model, multimodalContent = null) {
        return new Promise((resolve, reject) => {
            const messages = multimodalContent ? [{ role: 'user', content: multimodalContent }] : [{ role: 'user', content: prompt }];

            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://openrouter.ai/api/v1/chat/completions',
                headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
                data: JSON.stringify({ model, messages }),
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(JSON.parse(response.responseText));
                    } else {
                        const errorBody = JSON.parse(response.responseText);
                        const errorMessage = errorBody.error?.message || response.responseText;
                        reject(new Error(`API Error (${response.status}): ${errorMessage}`));
                    }
                },
                onerror: (response) => reject(new Error(`Network error: ${response.statusText || 'Failed to send request'}`))
            });
        });
    }

    // --- NEW: Function to describe an image using a vision model ---
    async function describeImageWithAI(imageUrl, apiKey) {
        console.log("Requesting description for image:", imageUrl);
        const multimodalContent = [
            { type: "text", text: "Describe this image concisely for a physics student. Focus on labels, values, and the relationships between components." },
            { type: "image_url", image_url: { "url": imageUrl } }
        ];
        try {
            const response = await callOpenRouter(null, apiKey, 'google/gemini-pro-vision', multimodalContent);
            return response.choices[0].message.content.trim();
        } catch (error) {
            console.error(`Failed to describe image ${imageUrl}:`, error);
            return `[Error describing image: ${error.message}]`; // Return an error message to be included in the prompt
        }
    }

    // --- Function to display the answer and control buttons ---
    function displayAnswer(questionBlock, answerText, isThinking = false, apiKey = null) {
        const answerContainerId = 'ai-answer-display';
        let answerContainer = questionBlock.querySelector(`#${answerContainerId}`);

        if (!answerContainer) {
            answerContainer = document.createElement('div');
            answerContainer.id = answerContainerId;
            const mainContentArea = questionBlock.querySelector('.pl-main');
            if (mainContentArea) mainContentArea.parentNode.insertBefore(answerContainer, mainContentArea);
            else questionBlock.appendChild(answerContainer);
        }

        const answerContent = `<div id="ai-answer-text" style="margin-bottom: 8px; white-space: pre-wrap;">${answerText.replace(/\n/g, '<br>')}</div>`;
        const controlButtonsHTML = `
            <div id="ai-controls" style="display: flex; gap: 8px; margin-top: 8px;">
                <button id="ai-less-concise" style="padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; background: #f0f0f0; cursor: pointer;">Less Concise</button>
                <button id="ai-more-concise" style="padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; background: #f0f0f0; cursor: pointer;">More Concise</button>
            </div>`;

        answerContainer.innerHTML = answerContent + (isThinking || !apiKey ? '' : controlButtonsHTML);

        Object.assign(answerContainer.style, {
            margin: '10px 0', padding: '12px', border: '1px solid', borderRadius: '5px',
            fontFamily: 'sans-serif', fontSize: '16px', lineHeight: '1.5',
            borderColor: isThinking ? '#ffc107' : '#007bff',
            backgroundColor: isThinking ? '#fff3cd' : '#e7f3ff',
            color: isThinking ? '#856404' : '#004085'
        });

        if (!isThinking && apiKey) {
            answerContainer.querySelector('#ai-more-concise').addEventListener('click', () => {
                const newLevel = Math.max(-2, parseInt(currentConciseness) - 1).toString();
                handleGenerateHotkey(apiKey, newLevel);
            });
            answerContainer.querySelector('#ai-less-concise').addEventListener('click', () => {
                const newLevel = Math.min(2, parseInt(currentConciseness) + 1).toString();
                handleGenerateHotkey(apiKey, newLevel);
            });
        }
    }

    // --- Function to hide all generated answers ---
    function hideAllAnswers() {
        console.log("--- AI Assistant: Hide hotkey pressed ---");
        document.querySelectorAll('#ai-answer-display').forEach(display => display.remove());
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址