IXL Auto Answer (OpenAI API Required)

IXL 解题脚本:Display-Only 模式使用 OpenAI SSE 流式实时渲染;Auto-Fill 保留自动填入。面板可拖拽+最小化,进度条、回滚、日志、令牌计数、默认折叠设置区。预置 gpt-4.1-nano。

נכון ליום 17-05-2025. ראה הגרסה האחרונה.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         IXL Auto Answer (OpenAI API Required)
// @namespace    http://tampermonkey.net/
// @version      9.1
// @license      GPL-3.0
// @description  IXL 解题脚本:Display-Only 模式使用 OpenAI SSE 流式实时渲染;Auto-Fill 保留自动填入。面板可拖拽+最小化,进度条、回滚、日志、令牌计数、默认折叠设置区。预置 gpt-4.1-nano。
// @match        https://*.ixl.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @require      https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js
// @require      https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
// ==/UserScript==

(function () {
    'use strict';

    /*───────────────────────────────────────────────────────────────────────
       0. LaTeX 包装 & 反转义
    ───────────────────────────────────────────────────────────────────────*/
    function wrapLatex(s) {
        // 修复 (-$\frac{a}{b}$) → $-\frac{a}{b}$,并给裸 \frac 补 $$
        s = s.replace(/\(-\$\\frac\{([^}]+)\}\{([^}]+)\}\$\)/g, (_, a, b) => `$-\\frac{${a}}{${b}}$`);
        return s.replace(/\\frac\{[^}]+\}\{[^}]+\}/g, m => `$${m}$`);
    }
    function unescapeDollar(s) {
        return s.replace(/\\\$/g, '$');
    }

    /*───────────────────────────────────────────────────────────────────────
       1. 配置存储与迁移
    ───────────────────────────────────────────────────────────────────────*/
    const OLD1 = localStorage.getItem('gpt4o-modelConfigs');
    const OLD2 = localStorage.getItem('ixlAutoAnswerConfigs');
    if (!localStorage.getItem('myNewIxLStorage')) {
        if (OLD1) {
            localStorage.setItem('myNewIxLStorage', OLD1);
            localStorage.removeItem('gpt4o-modelConfigs');
        }
        if (OLD2) {
            localStorage.setItem('myNewIxLStorage', OLD2);
            localStorage.removeItem('ixlAutoAnswerConfigs');
        }
    }
    const modelConfigs = JSON.parse(localStorage.getItem('myNewIxLStorage') || '{}');
    if (!modelConfigs['gpt-4.1']) {
        modelConfigs['gpt-4.1'] = {
            apiKey: '',
            apiBase: 'https://api.openai.com/v1/chat/completions',
            discovered: false,
            modelList: []
        };
    }
    const config = {
        selectedModel: 'gpt-4.1',
        language: localStorage.getItem('myIxLLang') || 'en',
        mode: 'displayOnly',        // "autoFill" | "displayOnly"
        autoSubmit: false,
        totalTokens: 0,
        lastState: null
    };
    function saveConfig() {
        localStorage.setItem('myNewIxLStorage', JSON.stringify(modelConfigs));
        localStorage.setItem('myIxLLang', config.language);
    }

    /*───────────────────────────────────────────────────────────────────────
       2. 多语言文案
    ───────────────────────────────────────────────────────────────────────*/
    const langText = {
        en: {
            panelTitle: "IXL Auto Answer (OpenAI API Required)",
            modeLabel: "Mode",
            modeAuto: "Auto Fill (Unstable)",
            modeDisp: "Display Answer Only (stream)",
            startButton: "Start Answering",
            rollbackButton: "Rollback",
            configAssistant: "Config Assistant",
            closeButton: "Close",
            logsButton: "Logs",
            logsHide: "Hide Logs",
            tokensLabel: "Tokens: ",
            statusIdle: "Status: Idle",
            statusWaiting: "Streaming...",
            statusDone: "Done.",
            requestError: "Request error: ",
            finalAnswerTitle: "Final Answer",
            stepsTitle: "Solution Steps",
            missingAnswerTag: "Missing <answer> tag",
            modelSelectLabel: "Model",
            modelDescLabel: "Model Description",
            customModelPlaceholder: "Custom model name",
            languageLabel: "Language",
            autoSubmitLabel: "Auto Submit",
            rentKeyButton: "Rent Key (Support Me!)",
            settingsKeyButton: "Toggle Settings",
            apiKeyLabel: "API Key",
            saveButton: "Save",
            testKeyButton: "Test Key",
            testKeyMsg: "Testing key...",
            keyOK: "API key valid.",
            keyBad: "API key invalid (missing 'test success').",
            placeKey: "Enter your API key",
            placeBase: "Enter your API base URL",
            apiBaseLabel: "API Base",
            refreshModels: "Refresh Models",
            getKeyLinkLabel: "Get API Key",
            disclaimAutoFill: "Warning: Auto Fill unstable.",
            minButton: "Min",
            shortAI: "Ask"
        },
        zh: {
            panelTitle: "IXL自动解题 (OpenAI)",
            modeLabel: "模式",
            modeAuto: "自动填入(不稳定)",
            modeDisp: "仅展示答案(流式)",
            startButton: "开始答题",
            rollbackButton: "撤回",
            configAssistant: "配置助手",
            closeButton: "关闭",
            logsButton: "日志",
            logsHide: "隐藏日志",
            tokensLabel: "用量: ",
            statusIdle: "状态:空闲",
            statusWaiting: "流式等待GPT...",
            statusDone: "完成。",
            requestError: "请求错误:",
            finalAnswerTitle: "最终答案",
            stepsTitle: "解题过程",
            missingAnswerTag: "缺少<answer>标签",
            modelSelectLabel: "模型",
            modelDescLabel: "模型介绍",
            customModelPlaceholder: "自定义模型名称",
            languageLabel: "语言",
            autoSubmitLabel: "自动提交",
            rentKeyButton: "租用Key (支持我!)",
            settingsKeyButton: "开关设置",
            apiKeyLabel: "API密钥",
            saveButton: "保存",
            testKeyButton: "测试密钥",
            testKeyMsg: "正在测试...",
            keyOK: "API密钥有效。",
            keyBad: "API密钥无效(缺'test success')",
            placeKey: "输入API密钥",
            placeBase: "输入API基础地址",
            apiBaseLabel: "API基础地址",
            refreshModels: "刷新模型列表",
            getKeyLinkLabel: "获取API Key",
            disclaimAutoFill: "警告:自动填入模式可能不稳定,请慎用。",
            minButton: "最小化",
            shortAI: "提问"
        }
    };

    /*───────────────────────────────────────────────────────────────────────
       3. 模型描述
    ───────────────────────────────────────────────────────────────────────*/
    const modelDescDB = {
        "gpt-4.1": "New Model, cheaper and a lot better than 4o",
        "gpt-4.1-mini": "New Model, cheaper and a little bit better than 4o",
        "gpt-4.1-nano": "Ultra-fast text-only.",
        "gpt-4o": "Solves images, cost-effective.",
        "gpt-4o-mini": "Text-only, cheaper.",
        "o1": "Best for images but slow & expensive.",
        "o3-mini": "Text-only, cheaper than o1.",
        "deepseek-reasoner": "No images, cheaper than o1.",
        "deepseek-chat": "No images, cheap & fast as 4o.",
        "o3": "Advanced multi-step reasoning model.",
        "o4-mini": "Compact variant of o4 architecture.",
        "chatgpt-4o-least": "RLHF version, can be error-prone.",
        "custom": "User-defined model"
    };

    /*───────────────────────────────────────────────────────────────────────
       4. 构建 UI
    ───────────────────────────────────────────────────────────────────────*/
    const panel = document.createElement("div");
    panel.id = "ixl-auto-panel";
    panel.innerHTML = `
<div class="ixl-header">
  <span id="panel-title">${langText[config.language].panelTitle}</span>
  <span id="token-count">${langText[config.language].tokensLabel}0</span>
  <button id="btn-min" title="${langText[config.language].minButton}">—</button>
  <button id="btn-logs">${langText[config.language].logsButton}</button>
  <button id="btn-close">${langText[config.language].closeButton}</button>
</div>
<div class="ixl-content" id="ixl-body">
  <div class="row">
    <label>${langText[config.language].modeLabel}:</label>
    <select id="sel-mode" style="width:100%;">
      <option value="autoFill">${langText[config.language].modeAuto}</option>
      <option value="displayOnly">${langText[config.language].modeDisp}</option>
    </select>
  </div>
  <div class="row" style="margin-top:8px; display:flex; gap:8px;">
    <button id="btn-start" class="btn-accent" style="flex:1;">${langText[config.language].startButton}</button>
    <button id="btn-rollback" class="btn-normal" style="flex:1;">${langText[config.language].rollbackButton}</button>
    <button id="btn-config-assist" class="btn-mini" style="flex:0;">${langText[config.language].configAssistant}</button>
  </div>
  <div id="answer-box" style="display:none; border:1px solid #999; padding:6px; background:#fff; margin-top:6px;">
    <h4 id="answer-title">${langText[config.language].finalAnswerTitle}</h4>
    <div id="answer-content" style="font-size:15px; font-weight:bold; color:#080;"></div>
    <hr/>
    <h5 id="steps-title">${langText[config.language].stepsTitle}</h5>
    <div id="steps-content" style="font-size:13px; color:#666;"></div>
  </div>
  <div id="progress-area" style="display:none; margin-top:8px;">
    <progress id="progress-bar" max="100" value="0" style="width:100%;"></progress>
    <span id="progress-label">${langText[config.language].statusWaiting}</span>
  </div>
  <p id="status-line" style="font-weight:bold; margin-top:6px;">${langText[config.language].statusIdle}</p>
  <div id="log-area" style="display:none; max-height:120px; overflow-y:auto; background:#fff; border:1px solid #888; margin-top:6px; padding:4px; font-family:monospace;"></div>
  <div class="row" style="margin-top:10px;">
    <button id="btn-rent" class="btn-normal" style="width:100%; font-weight:bold;">${langText[config.language].rentKeyButton}</button>
    <button id="btn-settings" class="btn-normal" style="width:100%; font-weight:bold; margin-top:6px;">${langText[config.language].settingsKeyButton}</button>
  </div>
  <div id="settings-area">
    <label>${langText[config.language].modelSelectLabel}:</label>
    <select id="sel-model" style="width:100%;"></select>
    <p id="model-desc" style="font-size:12px; color:#666; margin:4px 0;"></p>
    <div id="custom-model-area" style="display:none;"><input type="text" id="custom-model-input" style="width:100%;" placeholder="${langText[config.language].customModelPlaceholder}"/></div>
    <div class="row" style="margin-top:8px;">
      <label>${langText[config.language].languageLabel}:</label>
      <select id="sel-lang" style="width:100%;">
        <option value="en">English</option>
        <option value="zh">中文</option>
      </select>
    </div>
    <div id="auto-submit-row" style="margin-top:8px;"><label>${langText[config.language].autoSubmitLabel}:</label><input type="checkbox" id="chk-auto-submit"/></div>
    <div class="row" style="margin-top:10px;">
      <label>${langText[config.language].apiKeyLabel}:</label>
      <div style="display:flex; gap:4px; margin-top:4px;">
        <input type="password" id="txt-apikey" style="flex:1;" placeholder="${langText[config.language].placeKey}"/>
        <button id="btn-save-key">${langText[config.language].saveButton}</button>
        <button id="btn-test-key">${langText[config.language].testKeyButton}</button>
      </div>
    </div>
    <div class="row" style="margin-top:8px;">
      <label>${langText[config.language].apiBaseLabel}:</label>
      <div style="display:flex; gap:4px; margin-top:4px;">
        <input type="text" id="txt-apibase" style="flex:1;" placeholder="${langText[config.language].placeBase}"/>
        <button id="btn-save-base">${langText[config.language].saveButton}</button>
      </div>
    </div>
    <label style="display:block; margin-top:6px;">${langText[config.language].getKeyLinkLabel}:</label>
    <div style="display:flex; gap:4px; margin-top:4px;">
      <a id="link-getkey" href="#" target="_blank" class="link-btn" style="flex:1;">Link</a>
      <button id="btn-refresh" class="btn-normal" style="flex:1;">${langText[config.language].refreshModels}</button>
    </div>
  </div>
</div>`;
    document.body.appendChild(panel);

    GM_addStyle(`
#ixl-auto-panel{position:fixed;top:20px;right:20px;width:460px;max-height:500px;background:#fff;border-radius:6px;box-shadow:0 2px 10px rgba(0,0,0,.3);font-family:"Segoe UI",Arial,sans-serif;font-size:14px;overflow-y:auto;z-index:99999999;}
.ixl-header{background:#4caf50;color:#fff;display:flex;align-items:center;gap:6px;padding:6px;cursor:move;user-select:none;}
.ixl-header button{background:#fff;color:#333;border:none;border-radius:3px;padding:0 6px;font-weight:bold;cursor:pointer;}
.ixl-header button:hover{background:#eee;}
.ixl-content{padding:10px;}
#settings-area{display:none;}
.btn-accent{background:#f0ad4e;color:#fff;border:none;border-radius:4px;font-weight:bold;}
.btn-accent:hover{background:#ec971f;}
.btn-normal{background:#ddd;color:#333;border:none;border-radius:4px;}
.btn-normal:hover{background:#ccc;}
.btn-mini{background:#bbb;color:#333;border:none;border-radius:4px;font-size:12px;padding:4px 6px;}
.btn-mini:hover{background:#aaa;}
.link-btn{background:#2f8ee0;color:#fff;text-align:center;padding:6px;border-radius:4px;text-decoration:none;}
.link-btn:hover{opacity:.8;}
`);

    /*───────────────────────────────────────────────────────────────────────
       5. UI 参考
    ───────────────────────────────────────────────────────────────────────*/
    const UI = {
        panel,
        header: panel.querySelector('.ixl-header'),
        body: document.getElementById('ixl-body'),
        minBtn: document.getElementById('btn-min'),
        logsBtn: document.getElementById('btn-logs'),
        closeBtn: document.getElementById('btn-close'),
        tokenCount: document.getElementById('token-count'),
        modeSelect: document.getElementById('sel-mode'),
        startBtn: document.getElementById('btn-start'),
        rollbackBtn: document.getElementById('btn-rollback'),
        confAssistBtn: document.getElementById('btn-config-assist'),
        answerBox: document.getElementById('answer-box'),
        answerContent: document.getElementById('answer-content'),
        stepsContent: document.getElementById('steps-content'),
        progressArea: document.getElementById('progress-area'),
        progressBar: document.getElementById('progress-bar'),
        progressLabel: document.getElementById('progress-label'),
        statusLine: document.getElementById('status-line'),
        logArea: document.getElementById('log-area'),
        rentBtn: document.getElementById('btn-rent'),
        settingsBtn: document.getElementById('btn-settings'),
        settingsArea: document.getElementById('settings-area'),
        modelSelect:	document.getElementById('sel-model'),
        modelDesc:	document.getElementById('model-desc'),
        customModelArea: document.getElementById('custom-model-area'),
        customModelInput: document.getElementById('custom-model-input'),
        langSelect:	document.getElementById('sel-lang'),
        autoSubmitRow: document.getElementById('auto-submit-row'),
        autoSubmitToggle: document.getElementById('chk-auto-submit'),
        txtApiKey:	document.getElementById('txt-apikey'),
        saveKeyBtn:	document.getElementById('btn-save-key'),
        testKeyBtn:	document.getElementById('btn-test-key'),
        txtApiBase:	document.getElementById('txt-apibase'),
        saveBaseBtn:	document.getElementById('btn-save-base'),
        linkGetKey:	document.getElementById('link-getkey'),
        refreshBtn:	document.getElementById('btn-refresh')
    };

    /*───────────────────────────────────────────────────────────────────────
       6. 日志助手
    ───────────────────────────────────────────────────────────────────────*/
    function logMsg(msg) {
        const div = document.createElement('div');
        div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
        UI.logArea.appendChild(div);
        console.log('[IXL-Auto]', msg);
    }
    function logDump(label, val) {
        try {
            logMsg(`[DUMP] ${label}: ${JSON.stringify(val)}`);
        } catch (e) {
            logMsg(`[DUMP] ${label}: ${String(val)}`);
        }
    }

    /*───────────────────────────────────────────────────────────────────────
       7. 更新语言文本
    ───────────────────────────────────────────────────────────────────────*/
    function updateLangText() {
        UI.logsBtn.textContent = UI.logArea.style.display === 'none'
            ? langText[config.language].logsButton
            : langText[config.language].logsHide;
        UI.closeBtn.textContent = langText[config.language].closeButton;
        UI.tokenCount.textContent = langText[config.language].tokensLabel + config.totalTokens;
        UI.statusLine.textContent = langText[config.language].statusIdle;
        UI.progressLabel.textContent = langText[config.language].statusWaiting;
        UI.modeSelect.options[0].text = langText[config.language].modeAuto;
        UI.modeSelect.options[1].text = langText[config.language].modeDisp;
        UI.startBtn.textContent = langText[config.language].startButton;
        UI.rollbackBtn.textContent = langText[config.language].rollbackButton;
        UI.confAssistBtn.textContent = langText[config.language].configAssistant;
        document.getElementById('answer-title').textContent = langText[config.language].finalAnswerTitle;
        document.getElementById('steps-title').textContent = langText[config.language].stepsTitle;
        UI.txtApiKey.placeholder = langText[config.language].placeKey;
        UI.txtApiBase.placeholder = langText[config.language].placeBase;
        UI.saveKeyBtn.textContent = langText[config.language].saveButton;
        UI.testKeyBtn.textContent = langText[config.language].testKeyButton;
        UI.saveBaseBtn.textContent = langText[config.language].saveButton;
        UI.linkGetKey.textContent = langText[config.language].getKeyLinkLabel;
        UI.refreshBtn.textContent = langText[config.language].refreshModels;
        UI.rentBtn.textContent = langText[config.language].rentKeyButton;
        UI.settingsBtn.textContent = langText[config.language].settingsKeyButton;
        UI.minBtn.title = langText[config.language].minButton;
    }
    updateLangText();

    /*───────────────────────────────────────────────────────────────────────
       8. 构建模型选择
    ───────────────────────────────────────────────────────────────────────*/
    function buildModelSelect() {
        UI.modelSelect.innerHTML = '';
        const ogPre = document.createElement('optgroup');
        ogPre.label = 'Predefined';
        ['gpt-4.1','gpt-4.1-mini','gpt-4.1-nano','gpt-4o','gpt-4o-mini','o3','o4-mini','o1','o3-mini','deepseek-reasoner','deepseek-chat','chatgpt-4o-least']
            .forEach(m => {
                const o = document.createElement('option');
                o.value = m;
                o.textContent = m;
                ogPre.appendChild(o);
            });
        UI.modelSelect.appendChild(ogPre);
        const discovered = Object.keys(modelConfigs).filter(k => modelConfigs[k].discovered);
        if (discovered.length) {
            const ogDisc = document.createElement('optgroup');
            ogDisc.label = 'Discovered';
            discovered.forEach(m => {
                const o = document.createElement('option');
                o.value = m;
                o.textContent = m;
                ogDisc.appendChild(o);
            });
            UI.modelSelect.appendChild(ogDisc);
        }
        const optCust = document.createElement('option');
        optCust.value = 'custom';
        optCust.textContent = 'custom';
        UI.modelSelect.appendChild(optCust);

        UI.modelSelect.value = config.selectedModel in modelDescDB ? config.selectedModel : 'custom';
        UI.modelDesc.textContent = modelDescDB[config.selectedModel] || 'User-defined model';
        UI.customModelArea.style.display = config.selectedModel === 'custom' ? 'block' : 'none';
    }

    /*───────────────────────────────────────────────────────────────────────
       9. 拖拽 & 最小化
    ───────────────────────────────────────────────────────────────────────*/
    let dragOn = false, dx = 0, dy = 0;
    UI.header.addEventListener('mousedown', e => {
        if (e.target.tagName === 'BUTTON') return;
        dragOn = true;
        dx = e.clientX - panel.offsetLeft;
        dy = e.clientY - panel.offsetTop;
        panel.style.opacity = 0.8;
    });
    document.addEventListener('mousemove', e => {
        if (!dragOn) return;
        panel.style.left = (e.clientX - dx) + 'px';
        panel.style.top = (e.clientY - dy) + 'px';
    });
    document.addEventListener('mouseup', () => {
        dragOn = false;
        panel.style.opacity = 1;
    });
    let minimized = false;
    UI.minBtn.addEventListener('click', () => {
        minimized = !minimized;
        UI.body.style.display = minimized ? 'none' : 'block';
        UI.minBtn.textContent = minimized ? '+' : '—';
    });

    /*───────────────────────────────────────────────────────────────────────
       10. 事件绑定
    ───────────────────────────────────────────────────────────────────────*/
    UI.logsBtn.addEventListener('click', () => {
        UI.logArea.style.display = UI.logArea.style.display === 'none' ? 'block' : 'none';
        updateLangText();
    });
    UI.closeBtn.addEventListener('click', () => {
        panel.style.display = 'none';
    });
    UI.modeSelect.addEventListener('change', () => {
        config.mode = UI.modeSelect.value;
        if (config.mode === 'autoFill') {
            UI.answerBox.style.display = 'none';
            UI.autoSubmitRow.style.display = 'block';
            alert(langText[config.language].disclaimAutoFill);
        } else {
            UI.answerBox.style.display = 'none';
            UI.autoSubmitRow.style.display = 'none';
        }
    });
    UI.startBtn.addEventListener('click', startAnswer);
    UI.rollbackBtn.addEventListener('click', () => {
        if (config.lastState) {
            const d = getQuestionDiv();
            if (d) {
                d.innerHTML = config.lastState;
                logMsg('Rolled back.');
            }
        } else logMsg('No stored state.');
    });
    UI.confAssistBtn.addEventListener('click', openConfigAssistant);
    UI.autoSubmitToggle.addEventListener('change', () => {
        config.autoSubmit = UI.autoSubmitToggle.checked;
    });
    UI.modelSelect.addEventListener('change', () => {
        config.selectedModel = UI.modelSelect.value;
        if (!modelConfigs[config.selectedModel]) {
            modelConfigs[config.selectedModel] = {
                apiKey: '',
                apiBase: 'https://api.openai.com/v1/chat/completions',
                discovered: false,
                modelList: []
            };
        }
        UI.customModelArea.style.display = config.selectedModel === 'custom' ? 'block' : 'none';
        UI.modelDesc.textContent = modelDescDB[config.selectedModel] || 'User-defined model';
        UI.txtApiKey.value = modelConfigs[config.selectedModel].apiKey;
        UI.txtApiBase.value = modelConfigs[config.selectedModel].apiBase;
        if (config.selectedModel.toLowerCase().includes('deepseek')) {
            UI.txtApiBase.value = 'https://api.deepseek.com/v1/chat/completions';
            modelConfigs[config.selectedModel].apiBase = 'https://api.deepseek.com/v1/chat/completions';
        }
        updateManageLink();
    });
    UI.customModelInput.addEventListener('change', () => {
        const name = UI.customModelInput.value.trim();
        if (!name) return;
        config.selectedModel = name;
        if (!modelConfigs[name]) {
            modelConfigs[name] = {
                apiKey: '',
                apiBase: 'https://api.openai.com/v1/chat/completions',
                discovered: false,
                modelList: []
            };
        }
        buildModelSelect();
        UI.modelSelect.value = 'custom';
        UI.txtApiKey.value = modelConfigs[name].apiKey;
        UI.txtApiBase.value = modelConfigs[name].apiBase;
        updateManageLink();
    });
    UI.langSelect.addEventListener('change', () => {
        config.language = UI.langSelect.value;
        saveConfig();
        updateLangText();
    });
    UI.rentBtn.addEventListener('click', openRentPopup);
    UI.saveKeyBtn.addEventListener('click', () => {
        modelConfigs[config.selectedModel].apiKey = UI.txtApiKey.value.trim();
        saveConfig();
        logMsg('API key saved.');
    });
    UI.testKeyBtn.addEventListener('click', testApiKey);
    UI.saveBaseBtn.addEventListener('click', () => {
        modelConfigs[config.selectedModel].apiBase = UI.txtApiBase.value.trim();
        saveConfig();
        logMsg('API base saved.');
    });
    UI.refreshBtn.addEventListener('click', refreshModelList);
    UI.settingsBtn.addEventListener('click', () => {
        UI.settingsArea.style.display = UI.settingsArea.style.display === 'none' ? 'block' : 'none';
    });

    /*───────────────────────────────────────────────────────────────────────
       11. 更新管理链接
    ───────────────────────────────────────────────────────────────────────*/
    function updateManageLink() {
        const mod = config.selectedModel.toLowerCase();
        const link = mod.includes('deepseek')
            ? 'https://platform.deepseek.com/api_keys'
            : 'https://platform.openai.com/api-keys';
        modelConfigs[config.selectedModel].manageUrl = link;
        UI.linkGetKey.href = link;
        saveConfig();
    }

    /*───────────────────────────────────────────────────────────────────────
       12. 租用弹窗
    ───────────────────────────────────────────────────────────────────────*/
    function openRentPopup() {
        const overlay = document.createElement('div');
        Object.assign(overlay.style, {
            position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
            backgroundColor: 'rgba(0,0,0,0.4)', zIndex: 999999999
        });
        const box = document.createElement('div');
        Object.assign(box.style, {
            position: 'absolute', top: '50%', left: '50%',
            transform: 'translate(-50%,-50%)', width: '300px',
            backgroundColor: '#fff', borderRadius: '6px', padding: '10px'
        });
        box.innerHTML = `
<h3 style="margin-top:0;">Rent Key</h3>
<p>Contact me to rent an API key:</p>
<ul>
  <li>[email protected]</li>
  <li>[email protected]</li>
</ul>
<p>Thanks for supporting!</p>
<button id="rent-close-btn">${langText[config.language].closeButton}</button>
`;
        overlay.appendChild(box);
        document.body.appendChild(overlay);
        box.querySelector('#rent-close-btn').addEventListener('click', () => {
            document.body.removeChild(overlay);
        });
    }

    /*───────────────────────────────────────────────────────────────────────
       13. 测试 API Key
    ───────────────────────────────────────────────────────────────────────*/
    function testApiKey() {
        UI.statusLine.textContent = langText[config.language].testKeyMsg;
        const conf = modelConfigs[config.selectedModel];
        const payload = {
            model: config.selectedModel,
            messages: [
                { role: "system", content: "Test key." },
                { role: "user", content: "Please ONLY respond with: test success" }
            ]
        };
        GM_xmlhttpRequest({
            method: "POST",
            url: conf.apiBase,
            headers: {
                "Content-Type": "application/json",
                "Authorization": "Bearer " + conf.apiKey
            },
            data: JSON.stringify(payload),
            onload: (resp) => {
                UI.statusLine.textContent = langText[config.language].statusIdle;
                try {
                    const data = JSON.parse(resp.responseText);
                    const c = data.choices[0].message.content.toLowerCase();
                    alert(c.includes("test success") ? langText[config.language].keyOK : langText[config.language].keyBad);
                } catch (e) {
                    alert("Parse error: " + e);
                }
            },
            onerror: (err) => {
                UI.statusLine.textContent = langText[config.language].statusIdle;
                alert("Test error: " + JSON.stringify(err));
            }
        });
    }

    /*───────────────────────────────────────────────────────────────────────
       14. 刷新模型列表
    ───────────────────────────────────────────────────────────────────────*/
    function refreshModelList() {
        const c = modelConfigs[config.selectedModel];
        if (!c) return;
        const url = c.apiBase.replace("/chat/completions", "/models");
        logMsg("Refreshing models from: " + url);
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            headers: {
                "Authorization": "Bearer " + c.apiKey
            },
            onload: (resp) => {
                try {
                    const d = JSON.parse(resp.responseText);
                    logDump("Model Refresh", d);
                    if (Array.isArray(d.data)) {
                        const arr = d.data.map(x => x.id);
                        c.modelList = arr;
                        for (let m of arr) {
                            if (!modelConfigs[m]) {
                                modelConfigs[m] = {
                                    apiKey: c.apiKey,
                                    apiBase: c.apiBase,
                                    discovered: true,
                                    modelList: []
                                };
                            }
                        }
                        saveConfig();
                        buildModelSelect();
                        alert("Found models: " + arr.join(", "));
                    }
                } catch (e) {
                    alert("Parse error: " + e);
                }
            },
            onerror: (err) => {
                alert("Refresh error: " + JSON.stringify(err));
            }
        });
    }

    /*───────────────────────────────────────────────────────────────────────
       15. Config Assistant
    ───────────────────────────────────────────────────────────────────────*/
    function openConfigAssistant() {
        const overlay = document.createElement('div');
        Object.assign(overlay.style, {
            position: 'fixed', top: 0, left: 0,
            width: '100%', height: '100%',
            backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999999999
        });
        const box = document.createElement('div');
        Object.assign(box.style, {
            position: 'absolute',
            top: '50%', left: '50%',
            transform: 'translate(-50%,-50%)',
            width: '340px', backgroundColor: '#fff',
            borderRadius: '6px', padding: '10px'
        });
        box.innerHTML = `
<h3 style="margin-top:0;">${langText[config.language].configAssistant}</h3>
<textarea id="assistant-inp" style="width:100%;height:80px;"></textarea>
<button id="assistant-ask" style="margin-top:6px;">${langText[config.language].shortAI}</button>
<button id="assistant-close" style="margin-top:6px;">${langText[config.language].closeButton}</button>
<div id="assistant-out" style="margin-top:6px;border:1px solid #ccc;background:#fafafa;padding:6px;white-space:pre-wrap;max-height:200px;overflow-y:auto;"></div>`;
        overlay.appendChild(box);
        document.body.appendChild(overlay);
        const closeBtn = box.querySelector('#assistant-close');
        const askBtn = box.querySelector('#assistant-ask');
        const inp = box.querySelector('#assistant-inp');
        const outDiv = box.querySelector('#assistant-out');
        closeBtn.addEventListener('click', () => document.body.removeChild(overlay));
        askBtn.addEventListener('click', () => {
            const q = inp.value.trim();
            if (!q) return;
            outDiv.textContent = '(waiting…)';
            askAssistant(q,
                resp => { outDiv.innerHTML = marked.parse(resp || ''); },
                err => { outDiv.textContent = '[Error] ' + err; }
            );
        });
    }
    function askAssistant(question, onSuccess, onError) {
        const conf = modelConfigs[config.selectedModel];
        const payload = {
            model: config.selectedModel,
            messages: [
                { role: 'system', content: 'You are the config assistant. Provide concise, helpful configuration advice.' },
                { role: 'user', content: question }
            ]
        };
        GM_xmlhttpRequest({
            method: 'POST',
            url: conf.apiBase,
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + conf.apiKey
            },
            data: JSON.stringify(payload),
            onload: resp => {
                try {
                    const d = JSON.parse(resp.responseText);
                    onSuccess(d.choices[0].message.content);
                } catch (e) {
                    onError(e);
                }
            },
            onerror: err => { onError(err); }
        });
    }

    /*───────────────────────────────────────────────────────────────────────
       16. 获取题目 DIV / 捕获 LaTeX / 画布
    ───────────────────────────────────────────────────────────────────────*/
    function getQuestionDiv() {
        let d = document.evaluate(
            '/html/body/main/div/article/section/section/div/div[1]',
            document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
        ).singleNodeValue;
        if (!d) d = document.querySelector('main div.article, main>div, article');
        return d;
    }
    function captureLatex(div) {
        const arr = div.querySelectorAll('script[type="math/tex"], .MathJax, .mjx-chtml');
        if (arr.length) {
            let s = '';
            arr.forEach(e => s += e.textContent + '\n');
            return s;
        }
        return null;
    }
    function captureCanvas(div) {
        const c = div.querySelector('canvas');
        if (c) {
            const cv = document.createElement('canvas');
            cv.width = c.width; cv.height = c.height;
            cv.getContext('2d').drawImage(c, 0, 0);
            return cv.toDataURL('image/png').split(',')[1];
        }
        return null;
    }

    /*───────────────────────────────────────────────────────────────────────
       17. 进度条助手
    ───────────────────────────────────────────────────────────────────────*/
    let progTimer = null;
    function startProgress() {
        UI.progressArea.style.display = 'block';
        UI.progressBar.value = 0;
        progTimer = setInterval(() => {
            if (UI.progressBar.value < 90) UI.progressBar.value += 2;
        }, 200);
    }
    function stopProgress() {
        clearInterval(progTimer);
        UI.progressBar.value = 100;
        setTimeout(() => {
            UI.progressArea.style.display = 'none';
            UI.progressBar.value = 0;
        }, 400);
    }

    /*───────────────────────────────────────────────────────────────────────
       18. 主逻辑:startAnswer()
    ───────────────────────────────────────────────────────────────────────*/
    function startAnswer() {
        logMsg('Start pressed.');
        const qDiv = getQuestionDiv();
        if (!qDiv) { logMsg('Question div not found'); return; }
        config.lastState = qDiv.innerHTML;

        let userPrompt = 'HTML:\n' + qDiv.outerHTML + '\n';
        const latex = captureLatex(qDiv);
        if (latex) userPrompt += 'LaTeX:\n' + latex + '\n';
        else {
            const c64 = captureCanvas(qDiv);
            if (c64) userPrompt += 'Canvas image base64 attached.\n';
        }

        UI.answerBox.style.display = 'none';
        UI.statusLine.textContent = langText[config.language].statusWaiting;
        startProgress();

        const autoFillPrompt = `
You are an IXL math solver with automation support.
1. Solve the problem.
2. Provide final answer inside <answer>...</answer>.
3. After a blank line, show steps in Markdown.
4. At end, include one \`\`\`javascript block to autofill the input.`;

        const displayOnlyPrompt = `
You are an IXL math solver.
First return <answer>RESULT</answer> on its own line.
Then a blank line, then solution steps in Markdown.`;

        const messages = config.mode === 'autoFill'
            ? [{ role: 'system', content: autoFillPrompt }, { role: 'user', content: userPrompt }]
            : [{ role: 'system', content: displayOnlyPrompt }, { role: 'user', content: userPrompt }];

        const payload = {
            model: config.selectedModel,
            messages: messages,
            stream: config.mode === 'displayOnly'
        };
        const conf = modelConfigs[config.selectedModel];

        if (config.mode === 'displayOnly') {
            // SSE 流式
            let buffer = '';
            let answerDone = false;
            GM_xmlhttpRequest({
                method: 'POST',
                url: conf.apiBase,
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': 'Bearer ' + conf.apiKey,
                    'Accept': 'text/event-stream'
                },
                data: JSON.stringify(payload),
                onprogress: e => {
                    const chunk = e.responseText.substring(e.loadedPrev || 0);
                    e.loadedPrev = e.responseText.length;
                    const lines = chunk.split('\n').filter(l => l.startsWith('data:'));
                    lines.forEach(line => {
                        const data = line.replace(/^data:\s*/, '').trim();
                        if (data === '[DONE]') return;
                        try {
                            const json = JSON.parse(data);
                            const delta = json.choices?.[0]?.delta?.content;
                            if (!delta) return;
                            buffer += delta;
                            if (!answerDone) {
                                const m = buffer.match(/<answer>[\s\S]*?<\/answer>/i);
                                if (m) {
                                    answerDone = true;
                                    UI.answerContent.innerHTML = marked.parse(wrapLatex(m[0]));
                                    UI.answerBox.style.display = 'block';
                                    if (window.MathJax && typeof MathJax.typesetPromise === 'function') {
                                        MathJax.typesetPromise([UI.answerContent]).catch(() => {});
                                    }
                                }
                            }
                        } catch {}
                    });
                },
                onload: () => {
                    stopProgress();
                    const md = buffer.replace(/<answer>[\s\S]*?<\/answer>/i, '').trim();
                    UI.stepsContent.innerHTML = marked.parse(wrapLatex(unescapeDollar(md)));
                    if (window.MathJax && typeof MathJax.typesetPromise === 'function') {
                        MathJax.typesetPromise([UI.stepsContent]).catch(() => {});
                    }
                    UI.statusLine.textContent = langText[config.language].statusDone;
                },
                onerror: err => {
                    stopProgress();
                    UI.statusLine.textContent = 'Stream error';
                    logDump('SSE error', err);
                }
            });
            return;
        }

        // AutoFill 模式
        GM_xmlhttpRequest({
            method: 'POST',
            url: conf.apiBase,
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + conf.apiKey
            },
            data: JSON.stringify(payload),
            onload: resp => {
                stopProgress();
                try {
                    const d = JSON.parse(resp.responseText);
                    logDump('GPT raw', d);
                    if (d.usage?.total_tokens) {
                        config.totalTokens += d.usage.total_tokens;
                        UI.tokenCount.textContent = langText[config.language].tokensLabel + config.totalTokens;
                    }
                    const out = d.choices[0].message.content;
                    const ansMatch = out.match(/<answer>([\s\S]*?)<\/answer>/i);
                    const ansTag = ansMatch ? ansMatch[0] : `<answer>${langText[config.language].missingAnswerTag}</answer>`;
                    const steps = ansMatch ? out.replace(ansTag, '') : out;
                    UI.answerContent.innerHTML = marked.parse(wrapLatex(ansTag));
                    UI.stepsContent.innerHTML = marked.parse(wrapLatex(unescapeDollar(steps)));
                    if (window.MathJax && typeof MathJax.typesetPromise === 'function') {
                        MathJax.typesetPromise([UI.answerContent, UI.stepsContent]).catch(() => {});
                    }
                    const codeMatch = out.match(/```(?:javascript|js)?\s*([\s\S]*?)```/i);
                    if (codeMatch && codeMatch[1]) {
                        try {
                            (new Function(codeMatch[1]))();
                        } catch (e) {
                            logDump('RunJS error', e);
                        }
                        if (config.autoSubmit) {
                            const btn = document.querySelector('button.submit, button[class*=submit]');
                            if (btn) btn.click();
                        }
                    } else {
                        logMsg('No JS code block found');
                    }
                    UI.statusLine.textContent = langText[config.language].statusDone;
                } catch (e) {
                    UI.statusLine.textContent = 'Parse error';
                    logDump('Parse error', e);
                }
            },
            onerror: err => {
                stopProgress();
                UI.statusLine.textContent = langText[config.language].requestError + JSON.stringify(err);
                logDump('Request error', err);
            }
        });
    }

    /*───────────────────────────────────────────────────────────────────────
       19. 初始化
    ───────────────────────────────────────────────────────────────────────*/
    function initAll() {
        buildModelSelect();
        UI.txtApiKey.value = modelConfigs[config.selectedModel].apiKey;
        UI.txtApiBase.value = modelConfigs[config.selectedModel].apiBase;
        UI.modeSelect.value = config.mode;
        UI.autoSubmitRow.style.display = config.mode === 'autoFill' ? 'block' : 'none';
        UI.langSelect.value = config.language;
        updateManageLink();
        updateLangText();
        document.getElementById('settings-area').style.display = 'none';
        logMsg('IXL Auto Answer v9.1 loaded.');
    }

    window.MathJax = {
        tex: { inlineMath: [['$', '$'], ['\\(', '\\)']] },
        svg: { fontCache: 'global' }
    };

    initAll();

})();