IXL Auto Answer (OpenAI API Required)

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

目前為 2025-05-17 提交的版本,檢視 最新版本

// ==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();

})();