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