IXL Auto Answer (OpenAI API Required)

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

  1. // ==UserScript==
  2. // @name IXL Auto Answer (OpenAI API Required)
  3. // @namespace http://tampermonkey.net/
  4. // @version 9.1
  5. // @license GPL-3.0
  6. // @description IXL 解题脚本:Display-Only 模式使用 OpenAI SSE 流式实时渲染;Auto-Fill 保留自动填入。面板可拖拽+最小化,进度条、回滚、日志、令牌计数、默认折叠设置区。预置 gpt-4.1-nano。
  7. // @match https://*.ixl.com/*
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_addStyle
  10. // @require https://cdn.jsdelivr.net/npm/marked@4.3.0/marked.min.js
  11. // @require https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16.  
  17. /*───────────────────────────────────────────────────────────────────────
  18. 0. LaTeX 包装 & 反转义
  19. ───────────────────────────────────────────────────────────────────────*/
  20. function wrapLatex(s) {
  21. // 修复 (-$\frac{a}{b}$) → $-\frac{a}{b}$,并给裸 \frac 补 $$
  22. s = s.replace(/\(-\$\\frac\{([^}]+)\}\{([^}]+)\}\$\)/g, (_, a, b) => `$-\\frac{${a}}{${b}}$`);
  23. return s.replace(/\\frac\{[^}]+\}\{[^}]+\}/g, m => `$${m}$`);
  24. }
  25. function unescapeDollar(s) {
  26. return s.replace(/\\\$/g, '$');
  27. }
  28.  
  29. /*───────────────────────────────────────────────────────────────────────
  30. 1. 配置存储与迁移
  31. ───────────────────────────────────────────────────────────────────────*/
  32. const OLD1 = localStorage.getItem('gpt4o-modelConfigs');
  33. const OLD2 = localStorage.getItem('ixlAutoAnswerConfigs');
  34. if (!localStorage.getItem('myNewIxLStorage')) {
  35. if (OLD1) {
  36. localStorage.setItem('myNewIxLStorage', OLD1);
  37. localStorage.removeItem('gpt4o-modelConfigs');
  38. }
  39. if (OLD2) {
  40. localStorage.setItem('myNewIxLStorage', OLD2);
  41. localStorage.removeItem('ixlAutoAnswerConfigs');
  42. }
  43. }
  44. const modelConfigs = JSON.parse(localStorage.getItem('myNewIxLStorage') || '{}');
  45. if (!modelConfigs['gpt-4.1']) {
  46. modelConfigs['gpt-4.1'] = {
  47. apiKey: '',
  48. apiBase: 'https://api.openai.com/v1/chat/completions',
  49. discovered: false,
  50. modelList: []
  51. };
  52. }
  53. const config = {
  54. selectedModel: 'gpt-4.1',
  55. language: localStorage.getItem('myIxLLang') || 'en',
  56. mode: 'displayOnly', // "autoFill" | "displayOnly"
  57. autoSubmit: false,
  58. totalTokens: 0,
  59. lastState: null
  60. };
  61. function saveConfig() {
  62. localStorage.setItem('myNewIxLStorage', JSON.stringify(modelConfigs));
  63. localStorage.setItem('myIxLLang', config.language);
  64. }
  65.  
  66. /*───────────────────────────────────────────────────────────────────────
  67. 2. 多语言文案
  68. ───────────────────────────────────────────────────────────────────────*/
  69. const langText = {
  70. en: {
  71. panelTitle: "IXL Auto Answer (OpenAI API Required)",
  72. modeLabel: "Mode",
  73. modeAuto: "Auto Fill (Unstable)",
  74. modeDisp: "Display Answer Only (stream)",
  75. startButton: "Start Answering",
  76. rollbackButton: "Rollback",
  77. configAssistant: "Config Assistant",
  78. closeButton: "Close",
  79. logsButton: "Logs",
  80. logsHide: "Hide Logs",
  81. tokensLabel: "Tokens: ",
  82. statusIdle: "Status: Idle",
  83. statusWaiting: "Streaming...",
  84. statusDone: "Done.",
  85. requestError: "Request error: ",
  86. finalAnswerTitle: "Final Answer",
  87. stepsTitle: "Solution Steps",
  88. missingAnswerTag: "Missing <answer> tag",
  89. modelSelectLabel: "Model",
  90. modelDescLabel: "Model Description",
  91. customModelPlaceholder: "Custom model name",
  92. languageLabel: "Language",
  93. autoSubmitLabel: "Auto Submit",
  94. rentKeyButton: "Rent Key (Support Me!)",
  95. settingsKeyButton: "Toggle Settings",
  96. apiKeyLabel: "API Key",
  97. saveButton: "Save",
  98. testKeyButton: "Test Key",
  99. testKeyMsg: "Testing key...",
  100. keyOK: "API key valid.",
  101. keyBad: "API key invalid (missing 'test success').",
  102. placeKey: "Enter your API key",
  103. placeBase: "Enter your API base URL",
  104. apiBaseLabel: "API Base",
  105. refreshModels: "Refresh Models",
  106. getKeyLinkLabel: "Get API Key",
  107. disclaimAutoFill: "Warning: Auto Fill unstable.",
  108. minButton: "Min",
  109. shortAI: "Ask"
  110. },
  111. zh: {
  112. panelTitle: "IXL自动解题 (OpenAI)",
  113. modeLabel: "模式",
  114. modeAuto: "自动填入(不稳定)",
  115. modeDisp: "仅展示答案(流式)",
  116. startButton: "开始答题",
  117. rollbackButton: "撤回",
  118. configAssistant: "配置助手",
  119. closeButton: "关闭",
  120. logsButton: "日志",
  121. logsHide: "隐藏日志",
  122. tokensLabel: "用量: ",
  123. statusIdle: "状态:空闲",
  124. statusWaiting: "流式等待GPT...",
  125. statusDone: "完成。",
  126. requestError: "请求错误:",
  127. finalAnswerTitle: "最终答案",
  128. stepsTitle: "解题过程",
  129. missingAnswerTag: "缺少<answer>标签",
  130. modelSelectLabel: "模型",
  131. modelDescLabel: "模型介绍",
  132. customModelPlaceholder: "自定义模型名称",
  133. languageLabel: "语言",
  134. autoSubmitLabel: "自动提交",
  135. rentKeyButton: "租用Key (支持我!)",
  136. settingsKeyButton: "开关设置",
  137. apiKeyLabel: "API密钥",
  138. saveButton: "保存",
  139. testKeyButton: "测试密钥",
  140. testKeyMsg: "正在测试...",
  141. keyOK: "API密钥有效。",
  142. keyBad: "API密钥无效(缺'test success')",
  143. placeKey: "输入API密钥",
  144. placeBase: "输入API基础地址",
  145. apiBaseLabel: "API基础地址",
  146. refreshModels: "刷新模型列表",
  147. getKeyLinkLabel: "获取API Key",
  148. disclaimAutoFill: "警告:自动填入模式可能不稳定,请慎用。",
  149. minButton: "最小化",
  150. shortAI: "提问"
  151. }
  152. };
  153.  
  154. /*───────────────────────────────────────────────────────────────────────
  155. 3. 模型描述
  156. ───────────────────────────────────────────────────────────────────────*/
  157. const modelDescDB = {
  158. "gpt-4.1": "New Model, cheaper and a lot better than 4o",
  159. "gpt-4.1-mini": "New Model, cheaper and a little bit better than 4o",
  160. "gpt-4.1-nano": "Ultra-fast text-only.",
  161. "gpt-4o": "Solves images, cost-effective.",
  162. "gpt-4o-mini": "Text-only, cheaper.",
  163. "o1": "Best for images but slow & expensive.",
  164. "o3-mini": "Text-only, cheaper than o1.",
  165. "deepseek-reasoner": "No images, cheaper than o1.",
  166. "deepseek-chat": "No images, cheap & fast as 4o.",
  167. "o3": "Advanced multi-step reasoning model.",
  168. "o4-mini": "Compact variant of o4 architecture.",
  169. "chatgpt-4o-least": "RLHF version, can be error-prone.",
  170. "custom": "User-defined model"
  171. };
  172.  
  173. /*───────────────────────────────────────────────────────────────────────
  174. 4. 构建 UI
  175. ───────────────────────────────────────────────────────────────────────*/
  176. const panel = document.createElement("div");
  177. panel.id = "ixl-auto-panel";
  178. panel.innerHTML = `
  179. <div class="ixl-header">
  180. <span id="panel-title">${langText[config.language].panelTitle}</span>
  181. <span id="token-count">${langText[config.language].tokensLabel}0</span>
  182. <button id="btn-min" title="${langText[config.language].minButton}">—</button>
  183. <button id="btn-logs">${langText[config.language].logsButton}</button>
  184. <button id="btn-close">${langText[config.language].closeButton}</button>
  185. </div>
  186. <div class="ixl-content" id="ixl-body">
  187. <div class="row">
  188. <label>${langText[config.language].modeLabel}:</label>
  189. <select id="sel-mode" style="width:100%;">
  190. <option value="autoFill">${langText[config.language].modeAuto}</option>
  191. <option value="displayOnly">${langText[config.language].modeDisp}</option>
  192. </select>
  193. </div>
  194. <div class="row" style="margin-top:8px; display:flex; gap:8px;">
  195. <button id="btn-start" class="btn-accent" style="flex:1;">${langText[config.language].startButton}</button>
  196. <button id="btn-rollback" class="btn-normal" style="flex:1;">${langText[config.language].rollbackButton}</button>
  197. <button id="btn-config-assist" class="btn-mini" style="flex:0;">${langText[config.language].configAssistant}</button>
  198. </div>
  199. <div id="answer-box" style="display:none; border:1px solid #999; padding:6px; background:#fff; margin-top:6px;">
  200. <h4 id="answer-title">${langText[config.language].finalAnswerTitle}</h4>
  201. <div id="answer-content" style="font-size:15px; font-weight:bold; color:#080;"></div>
  202. <hr/>
  203. <h5 id="steps-title">${langText[config.language].stepsTitle}</h5>
  204. <div id="steps-content" style="font-size:13px; color:#666;"></div>
  205. </div>
  206. <div id="progress-area" style="display:none; margin-top:8px;">
  207. <progress id="progress-bar" max="100" value="0" style="width:100%;"></progress>
  208. <span id="progress-label">${langText[config.language].statusWaiting}</span>
  209. </div>
  210. <p id="status-line" style="font-weight:bold; margin-top:6px;">${langText[config.language].statusIdle}</p>
  211. <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>
  212. <div class="row" style="margin-top:10px;">
  213. <button id="btn-rent" class="btn-normal" style="width:100%; font-weight:bold;">${langText[config.language].rentKeyButton}</button>
  214. <button id="btn-settings" class="btn-normal" style="width:100%; font-weight:bold; margin-top:6px;">${langText[config.language].settingsKeyButton}</button>
  215. </div>
  216. <div id="settings-area">
  217. <label>${langText[config.language].modelSelectLabel}:</label>
  218. <select id="sel-model" style="width:100%;"></select>
  219. <p id="model-desc" style="font-size:12px; color:#666; margin:4px 0;"></p>
  220. <div id="custom-model-area" style="display:none;"><input type="text" id="custom-model-input" style="width:100%;" placeholder="${langText[config.language].customModelPlaceholder}"/></div>
  221. <div class="row" style="margin-top:8px;">
  222. <label>${langText[config.language].languageLabel}:</label>
  223. <select id="sel-lang" style="width:100%;">
  224. <option value="en">English</option>
  225. <option value="zh">中文</option>
  226. </select>
  227. </div>
  228. <div id="auto-submit-row" style="margin-top:8px;"><label>${langText[config.language].autoSubmitLabel}:</label><input type="checkbox" id="chk-auto-submit"/></div>
  229. <div class="row" style="margin-top:10px;">
  230. <label>${langText[config.language].apiKeyLabel}:</label>
  231. <div style="display:flex; gap:4px; margin-top:4px;">
  232. <input type="password" id="txt-apikey" style="flex:1;" placeholder="${langText[config.language].placeKey}"/>
  233. <button id="btn-save-key">${langText[config.language].saveButton}</button>
  234. <button id="btn-test-key">${langText[config.language].testKeyButton}</button>
  235. </div>
  236. </div>
  237. <div class="row" style="margin-top:8px;">
  238. <label>${langText[config.language].apiBaseLabel}:</label>
  239. <div style="display:flex; gap:4px; margin-top:4px;">
  240. <input type="text" id="txt-apibase" style="flex:1;" placeholder="${langText[config.language].placeBase}"/>
  241. <button id="btn-save-base">${langText[config.language].saveButton}</button>
  242. </div>
  243. </div>
  244. <label style="display:block; margin-top:6px;">${langText[config.language].getKeyLinkLabel}:</label>
  245. <div style="display:flex; gap:4px; margin-top:4px;">
  246. <a id="link-getkey" href="#" target="_blank" class="link-btn" style="flex:1;">Link</a>
  247. <button id="btn-refresh" class="btn-normal" style="flex:1;">${langText[config.language].refreshModels}</button>
  248. </div>
  249. </div>
  250. </div>`;
  251. document.body.appendChild(panel);
  252.  
  253. GM_addStyle(`
  254. #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;}
  255. .ixl-header{background:#4caf50;color:#fff;display:flex;align-items:center;gap:6px;padding:6px;cursor:move;user-select:none;}
  256. .ixl-header button{background:#fff;color:#333;border:none;border-radius:3px;padding:0 6px;font-weight:bold;cursor:pointer;}
  257. .ixl-header button:hover{background:#eee;}
  258. .ixl-content{padding:10px;}
  259. #settings-area{display:none;}
  260. .btn-accent{background:#f0ad4e;color:#fff;border:none;border-radius:4px;font-weight:bold;}
  261. .btn-accent:hover{background:#ec971f;}
  262. .btn-normal{background:#ddd;color:#333;border:none;border-radius:4px;}
  263. .btn-normal:hover{background:#ccc;}
  264. .btn-mini{background:#bbb;color:#333;border:none;border-radius:4px;font-size:12px;padding:4px 6px;}
  265. .btn-mini:hover{background:#aaa;}
  266. .link-btn{background:#2f8ee0;color:#fff;text-align:center;padding:6px;border-radius:4px;text-decoration:none;}
  267. .link-btn:hover{opacity:.8;}
  268. `);
  269.  
  270. /*───────────────────────────────────────────────────────────────────────
  271. 5. UI 参考
  272. ───────────────────────────────────────────────────────────────────────*/
  273. const UI = {
  274. panel,
  275. header: panel.querySelector('.ixl-header'),
  276. body: document.getElementById('ixl-body'),
  277. minBtn: document.getElementById('btn-min'),
  278. logsBtn: document.getElementById('btn-logs'),
  279. closeBtn: document.getElementById('btn-close'),
  280. tokenCount: document.getElementById('token-count'),
  281. modeSelect: document.getElementById('sel-mode'),
  282. startBtn: document.getElementById('btn-start'),
  283. rollbackBtn: document.getElementById('btn-rollback'),
  284. confAssistBtn: document.getElementById('btn-config-assist'),
  285. answerBox: document.getElementById('answer-box'),
  286. answerContent: document.getElementById('answer-content'),
  287. stepsContent: document.getElementById('steps-content'),
  288. progressArea: document.getElementById('progress-area'),
  289. progressBar: document.getElementById('progress-bar'),
  290. progressLabel: document.getElementById('progress-label'),
  291. statusLine: document.getElementById('status-line'),
  292. logArea: document.getElementById('log-area'),
  293. rentBtn: document.getElementById('btn-rent'),
  294. settingsBtn: document.getElementById('btn-settings'),
  295. settingsArea: document.getElementById('settings-area'),
  296. modelSelect: document.getElementById('sel-model'),
  297. modelDesc: document.getElementById('model-desc'),
  298. customModelArea: document.getElementById('custom-model-area'),
  299. customModelInput: document.getElementById('custom-model-input'),
  300. langSelect: document.getElementById('sel-lang'),
  301. autoSubmitRow: document.getElementById('auto-submit-row'),
  302. autoSubmitToggle: document.getElementById('chk-auto-submit'),
  303. txtApiKey: document.getElementById('txt-apikey'),
  304. saveKeyBtn: document.getElementById('btn-save-key'),
  305. testKeyBtn: document.getElementById('btn-test-key'),
  306. txtApiBase: document.getElementById('txt-apibase'),
  307. saveBaseBtn: document.getElementById('btn-save-base'),
  308. linkGetKey: document.getElementById('link-getkey'),
  309. refreshBtn: document.getElementById('btn-refresh')
  310. };
  311.  
  312. /*───────────────────────────────────────────────────────────────────────
  313. 6. 日志助手
  314. ───────────────────────────────────────────────────────────────────────*/
  315. function logMsg(msg) {
  316. const div = document.createElement('div');
  317. div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
  318. UI.logArea.appendChild(div);
  319. console.log('[IXL-Auto]', msg);
  320. }
  321. function logDump(label, val) {
  322. try {
  323. logMsg(`[DUMP] ${label}: ${JSON.stringify(val)}`);
  324. } catch (e) {
  325. logMsg(`[DUMP] ${label}: ${String(val)}`);
  326. }
  327. }
  328.  
  329. /*───────────────────────────────────────────────────────────────────────
  330. 7. 更新语言文本
  331. ───────────────────────────────────────────────────────────────────────*/
  332. function updateLangText() {
  333. UI.logsBtn.textContent = UI.logArea.style.display === 'none'
  334. ? langText[config.language].logsButton
  335. : langText[config.language].logsHide;
  336. UI.closeBtn.textContent = langText[config.language].closeButton;
  337. UI.tokenCount.textContent = langText[config.language].tokensLabel + config.totalTokens;
  338. UI.statusLine.textContent = langText[config.language].statusIdle;
  339. UI.progressLabel.textContent = langText[config.language].statusWaiting;
  340. UI.modeSelect.options[0].text = langText[config.language].modeAuto;
  341. UI.modeSelect.options[1].text = langText[config.language].modeDisp;
  342. UI.startBtn.textContent = langText[config.language].startButton;
  343. UI.rollbackBtn.textContent = langText[config.language].rollbackButton;
  344. UI.confAssistBtn.textContent = langText[config.language].configAssistant;
  345. document.getElementById('answer-title').textContent = langText[config.language].finalAnswerTitle;
  346. document.getElementById('steps-title').textContent = langText[config.language].stepsTitle;
  347. UI.txtApiKey.placeholder = langText[config.language].placeKey;
  348. UI.txtApiBase.placeholder = langText[config.language].placeBase;
  349. UI.saveKeyBtn.textContent = langText[config.language].saveButton;
  350. UI.testKeyBtn.textContent = langText[config.language].testKeyButton;
  351. UI.saveBaseBtn.textContent = langText[config.language].saveButton;
  352. UI.linkGetKey.textContent = langText[config.language].getKeyLinkLabel;
  353. UI.refreshBtn.textContent = langText[config.language].refreshModels;
  354. UI.rentBtn.textContent = langText[config.language].rentKeyButton;
  355. UI.settingsBtn.textContent = langText[config.language].settingsKeyButton;
  356. UI.minBtn.title = langText[config.language].minButton;
  357. }
  358. updateLangText();
  359.  
  360. /*───────────────────────────────────────────────────────────────────────
  361. 8. 构建模型选择
  362. ───────────────────────────────────────────────────────────────────────*/
  363. function buildModelSelect() {
  364. UI.modelSelect.innerHTML = '';
  365. const ogPre = document.createElement('optgroup');
  366. ogPre.label = 'Predefined';
  367. ['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']
  368. .forEach(m => {
  369. const o = document.createElement('option');
  370. o.value = m;
  371. o.textContent = m;
  372. ogPre.appendChild(o);
  373. });
  374. UI.modelSelect.appendChild(ogPre);
  375. const discovered = Object.keys(modelConfigs).filter(k => modelConfigs[k].discovered);
  376. if (discovered.length) {
  377. const ogDisc = document.createElement('optgroup');
  378. ogDisc.label = 'Discovered';
  379. discovered.forEach(m => {
  380. const o = document.createElement('option');
  381. o.value = m;
  382. o.textContent = m;
  383. ogDisc.appendChild(o);
  384. });
  385. UI.modelSelect.appendChild(ogDisc);
  386. }
  387. const optCust = document.createElement('option');
  388. optCust.value = 'custom';
  389. optCust.textContent = 'custom';
  390. UI.modelSelect.appendChild(optCust);
  391.  
  392. UI.modelSelect.value = config.selectedModel in modelDescDB ? config.selectedModel : 'custom';
  393. UI.modelDesc.textContent = modelDescDB[config.selectedModel] || 'User-defined model';
  394. UI.customModelArea.style.display = config.selectedModel === 'custom' ? 'block' : 'none';
  395. }
  396.  
  397. /*───────────────────────────────────────────────────────────────────────
  398. 9. 拖拽 & 最小化
  399. ───────────────────────────────────────────────────────────────────────*/
  400. let dragOn = false, dx = 0, dy = 0;
  401. UI.header.addEventListener('mousedown', e => {
  402. if (e.target.tagName === 'BUTTON') return;
  403. dragOn = true;
  404. dx = e.clientX - panel.offsetLeft;
  405. dy = e.clientY - panel.offsetTop;
  406. panel.style.opacity = 0.8;
  407. });
  408. document.addEventListener('mousemove', e => {
  409. if (!dragOn) return;
  410. panel.style.left = (e.clientX - dx) + 'px';
  411. panel.style.top = (e.clientY - dy) + 'px';
  412. });
  413. document.addEventListener('mouseup', () => {
  414. dragOn = false;
  415. panel.style.opacity = 1;
  416. });
  417. let minimized = false;
  418. UI.minBtn.addEventListener('click', () => {
  419. minimized = !minimized;
  420. UI.body.style.display = minimized ? 'none' : 'block';
  421. UI.minBtn.textContent = minimized ? '+' : '—';
  422. });
  423.  
  424. /*───────────────────────────────────────────────────────────────────────
  425. 10. 事件绑定
  426. ───────────────────────────────────────────────────────────────────────*/
  427. UI.logsBtn.addEventListener('click', () => {
  428. UI.logArea.style.display = UI.logArea.style.display === 'none' ? 'block' : 'none';
  429. updateLangText();
  430. });
  431. UI.closeBtn.addEventListener('click', () => {
  432. panel.style.display = 'none';
  433. });
  434. UI.modeSelect.addEventListener('change', () => {
  435. config.mode = UI.modeSelect.value;
  436. if (config.mode === 'autoFill') {
  437. UI.answerBox.style.display = 'none';
  438. UI.autoSubmitRow.style.display = 'block';
  439. alert(langText[config.language].disclaimAutoFill);
  440. } else {
  441. UI.answerBox.style.display = 'none';
  442. UI.autoSubmitRow.style.display = 'none';
  443. }
  444. });
  445. UI.startBtn.addEventListener('click', startAnswer);
  446. UI.rollbackBtn.addEventListener('click', () => {
  447. if (config.lastState) {
  448. const d = getQuestionDiv();
  449. if (d) {
  450. d.innerHTML = config.lastState;
  451. logMsg('Rolled back.');
  452. }
  453. } else logMsg('No stored state.');
  454. });
  455. UI.confAssistBtn.addEventListener('click', openConfigAssistant);
  456. UI.autoSubmitToggle.addEventListener('change', () => {
  457. config.autoSubmit = UI.autoSubmitToggle.checked;
  458. });
  459. UI.modelSelect.addEventListener('change', () => {
  460. config.selectedModel = UI.modelSelect.value;
  461. if (!modelConfigs[config.selectedModel]) {
  462. modelConfigs[config.selectedModel] = {
  463. apiKey: '',
  464. apiBase: 'https://api.openai.com/v1/chat/completions',
  465. discovered: false,
  466. modelList: []
  467. };
  468. }
  469. UI.customModelArea.style.display = config.selectedModel === 'custom' ? 'block' : 'none';
  470. UI.modelDesc.textContent = modelDescDB[config.selectedModel] || 'User-defined model';
  471. UI.txtApiKey.value = modelConfigs[config.selectedModel].apiKey;
  472. UI.txtApiBase.value = modelConfigs[config.selectedModel].apiBase;
  473. if (config.selectedModel.toLowerCase().includes('deepseek')) {
  474. UI.txtApiBase.value = 'https://api.deepseek.com/v1/chat/completions';
  475. modelConfigs[config.selectedModel].apiBase = 'https://api.deepseek.com/v1/chat/completions';
  476. }
  477. updateManageLink();
  478. });
  479. UI.customModelInput.addEventListener('change', () => {
  480. const name = UI.customModelInput.value.trim();
  481. if (!name) return;
  482. config.selectedModel = name;
  483. if (!modelConfigs[name]) {
  484. modelConfigs[name] = {
  485. apiKey: '',
  486. apiBase: 'https://api.openai.com/v1/chat/completions',
  487. discovered: false,
  488. modelList: []
  489. };
  490. }
  491. buildModelSelect();
  492. UI.modelSelect.value = 'custom';
  493. UI.txtApiKey.value = modelConfigs[name].apiKey;
  494. UI.txtApiBase.value = modelConfigs[name].apiBase;
  495. updateManageLink();
  496. });
  497. UI.langSelect.addEventListener('change', () => {
  498. config.language = UI.langSelect.value;
  499. saveConfig();
  500. updateLangText();
  501. });
  502. UI.rentBtn.addEventListener('click', openRentPopup);
  503. UI.saveKeyBtn.addEventListener('click', () => {
  504. modelConfigs[config.selectedModel].apiKey = UI.txtApiKey.value.trim();
  505. saveConfig();
  506. logMsg('API key saved.');
  507. });
  508. UI.testKeyBtn.addEventListener('click', testApiKey);
  509. UI.saveBaseBtn.addEventListener('click', () => {
  510. modelConfigs[config.selectedModel].apiBase = UI.txtApiBase.value.trim();
  511. saveConfig();
  512. logMsg('API base saved.');
  513. });
  514. UI.refreshBtn.addEventListener('click', refreshModelList);
  515. UI.settingsBtn.addEventListener('click', () => {
  516. UI.settingsArea.style.display = UI.settingsArea.style.display === 'none' ? 'block' : 'none';
  517. });
  518.  
  519. /*───────────────────────────────────────────────────────────────────────
  520. 11. 更新管理链接
  521. ───────────────────────────────────────────────────────────────────────*/
  522. function updateManageLink() {
  523. const mod = config.selectedModel.toLowerCase();
  524. const link = mod.includes('deepseek')
  525. ? 'https://platform.deepseek.com/api_keys'
  526. : 'https://platform.openai.com/api-keys';
  527. modelConfigs[config.selectedModel].manageUrl = link;
  528. UI.linkGetKey.href = link;
  529. saveConfig();
  530. }
  531.  
  532. /*───────────────────────────────────────────────────────────────────────
  533. 12. 租用弹窗
  534. ───────────────────────────────────────────────────────────────────────*/
  535. function openRentPopup() {
  536. const overlay = document.createElement('div');
  537. Object.assign(overlay.style, {
  538. position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
  539. backgroundColor: 'rgba(0,0,0,0.4)', zIndex: 999999999
  540. });
  541. const box = document.createElement('div');
  542. Object.assign(box.style, {
  543. position: 'absolute', top: '50%', left: '50%',
  544. transform: 'translate(-50%,-50%)', width: '300px',
  545. backgroundColor: '#fff', borderRadius: '6px', padding: '10px'
  546. });
  547. box.innerHTML = `
  548. <h3 style="margin-top:0;">Rent Key</h3>
  549. <p>Contact me to rent an API key:</p>
  550. <ul>
  551. <li>felixliujy@gmail.com</li>
  552. <li>admin@obanarchy.org</li>
  553. </ul>
  554. <p>Thanks for supporting!</p>
  555. <button id="rent-close-btn">${langText[config.language].closeButton}</button>
  556. `;
  557. overlay.appendChild(box);
  558. document.body.appendChild(overlay);
  559. box.querySelector('#rent-close-btn').addEventListener('click', () => {
  560. document.body.removeChild(overlay);
  561. });
  562. }
  563.  
  564. /*───────────────────────────────────────────────────────────────────────
  565. 13. 测试 API Key
  566. ───────────────────────────────────────────────────────────────────────*/
  567. function testApiKey() {
  568. UI.statusLine.textContent = langText[config.language].testKeyMsg;
  569. const conf = modelConfigs[config.selectedModel];
  570. const payload = {
  571. model: config.selectedModel,
  572. messages: [
  573. { role: "system", content: "Test key." },
  574. { role: "user", content: "Please ONLY respond with: test success" }
  575. ]
  576. };
  577. GM_xmlhttpRequest({
  578. method: "POST",
  579. url: conf.apiBase,
  580. headers: {
  581. "Content-Type": "application/json",
  582. "Authorization": "Bearer " + conf.apiKey
  583. },
  584. data: JSON.stringify(payload),
  585. onload: (resp) => {
  586. UI.statusLine.textContent = langText[config.language].statusIdle;
  587. try {
  588. const data = JSON.parse(resp.responseText);
  589. const c = data.choices[0].message.content.toLowerCase();
  590. alert(c.includes("test success") ? langText[config.language].keyOK : langText[config.language].keyBad);
  591. } catch (e) {
  592. alert("Parse error: " + e);
  593. }
  594. },
  595. onerror: (err) => {
  596. UI.statusLine.textContent = langText[config.language].statusIdle;
  597. alert("Test error: " + JSON.stringify(err));
  598. }
  599. });
  600. }
  601.  
  602. /*───────────────────────────────────────────────────────────────────────
  603. 14. 刷新模型列表
  604. ───────────────────────────────────────────────────────────────────────*/
  605. function refreshModelList() {
  606. const c = modelConfigs[config.selectedModel];
  607. if (!c) return;
  608. const url = c.apiBase.replace("/chat/completions", "/models");
  609. logMsg("Refreshing models from: " + url);
  610. GM_xmlhttpRequest({
  611. method: "GET",
  612. url: url,
  613. headers: {
  614. "Authorization": "Bearer " + c.apiKey
  615. },
  616. onload: (resp) => {
  617. try {
  618. const d = JSON.parse(resp.responseText);
  619. logDump("Model Refresh", d);
  620. if (Array.isArray(d.data)) {
  621. const arr = d.data.map(x => x.id);
  622. c.modelList = arr;
  623. for (let m of arr) {
  624. if (!modelConfigs[m]) {
  625. modelConfigs[m] = {
  626. apiKey: c.apiKey,
  627. apiBase: c.apiBase,
  628. discovered: true,
  629. modelList: []
  630. };
  631. }
  632. }
  633. saveConfig();
  634. buildModelSelect();
  635. alert("Found models: " + arr.join(", "));
  636. }
  637. } catch (e) {
  638. alert("Parse error: " + e);
  639. }
  640. },
  641. onerror: (err) => {
  642. alert("Refresh error: " + JSON.stringify(err));
  643. }
  644. });
  645. }
  646.  
  647. /*───────────────────────────────────────────────────────────────────────
  648. 15. Config Assistant
  649. ───────────────────────────────────────────────────────────────────────*/
  650. function openConfigAssistant() {
  651. const overlay = document.createElement('div');
  652. Object.assign(overlay.style, {
  653. position: 'fixed', top: 0, left: 0,
  654. width: '100%', height: '100%',
  655. backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999999999
  656. });
  657. const box = document.createElement('div');
  658. Object.assign(box.style, {
  659. position: 'absolute',
  660. top: '50%', left: '50%',
  661. transform: 'translate(-50%,-50%)',
  662. width: '340px', backgroundColor: '#fff',
  663. borderRadius: '6px', padding: '10px'
  664. });
  665. box.innerHTML = `
  666. <h3 style="margin-top:0;">${langText[config.language].configAssistant}</h3>
  667. <textarea id="assistant-inp" style="width:100%;height:80px;"></textarea>
  668. <button id="assistant-ask" style="margin-top:6px;">${langText[config.language].shortAI}</button>
  669. <button id="assistant-close" style="margin-top:6px;">${langText[config.language].closeButton}</button>
  670. <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>`;
  671. overlay.appendChild(box);
  672. document.body.appendChild(overlay);
  673. const closeBtn = box.querySelector('#assistant-close');
  674. const askBtn = box.querySelector('#assistant-ask');
  675. const inp = box.querySelector('#assistant-inp');
  676. const outDiv = box.querySelector('#assistant-out');
  677. closeBtn.addEventListener('click', () => document.body.removeChild(overlay));
  678. askBtn.addEventListener('click', () => {
  679. const q = inp.value.trim();
  680. if (!q) return;
  681. outDiv.textContent = '(waiting…)';
  682. askAssistant(q,
  683. resp => { outDiv.innerHTML = marked.parse(resp || ''); },
  684. err => { outDiv.textContent = '[Error] ' + err; }
  685. );
  686. });
  687. }
  688. function askAssistant(question, onSuccess, onError) {
  689. const conf = modelConfigs[config.selectedModel];
  690. const payload = {
  691. model: config.selectedModel,
  692. messages: [
  693. { role: 'system', content: 'You are the config assistant. Provide concise, helpful configuration advice.' },
  694. { role: 'user', content: question }
  695. ]
  696. };
  697. GM_xmlhttpRequest({
  698. method: 'POST',
  699. url: conf.apiBase,
  700. headers: {
  701. 'Content-Type': 'application/json',
  702. 'Authorization': 'Bearer ' + conf.apiKey
  703. },
  704. data: JSON.stringify(payload),
  705. onload: resp => {
  706. try {
  707. const d = JSON.parse(resp.responseText);
  708. onSuccess(d.choices[0].message.content);
  709. } catch (e) {
  710. onError(e);
  711. }
  712. },
  713. onerror: err => { onError(err); }
  714. });
  715. }
  716.  
  717. /*───────────────────────────────────────────────────────────────────────
  718. 16. 获取题目 DIV / 捕获 LaTeX / 画布
  719. ───────────────────────────────────────────────────────────────────────*/
  720. function getQuestionDiv() {
  721. let d = document.evaluate(
  722. '/html/body/main/div/article/section/section/div/div[1]',
  723. document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
  724. ).singleNodeValue;
  725. if (!d) d = document.querySelector('main div.article, main>div, article');
  726. return d;
  727. }
  728. function captureLatex(div) {
  729. const arr = div.querySelectorAll('script[type="math/tex"], .MathJax, .mjx-chtml');
  730. if (arr.length) {
  731. let s = '';
  732. arr.forEach(e => s += e.textContent + '\n');
  733. return s;
  734. }
  735. return null;
  736. }
  737. function captureCanvas(div) {
  738. const c = div.querySelector('canvas');
  739. if (c) {
  740. const cv = document.createElement('canvas');
  741. cv.width = c.width; cv.height = c.height;
  742. cv.getContext('2d').drawImage(c, 0, 0);
  743. return cv.toDataURL('image/png').split(',')[1];
  744. }
  745. return null;
  746. }
  747.  
  748. /*───────────────────────────────────────────────────────────────────────
  749. 17. 进度条助手
  750. ───────────────────────────────────────────────────────────────────────*/
  751. let progTimer = null;
  752. function startProgress() {
  753. UI.progressArea.style.display = 'block';
  754. UI.progressBar.value = 0;
  755. progTimer = setInterval(() => {
  756. if (UI.progressBar.value < 90) UI.progressBar.value += 2;
  757. }, 200);
  758. }
  759. function stopProgress() {
  760. clearInterval(progTimer);
  761. UI.progressBar.value = 100;
  762. setTimeout(() => {
  763. UI.progressArea.style.display = 'none';
  764. UI.progressBar.value = 0;
  765. }, 400);
  766. }
  767.  
  768. /*───────────────────────────────────────────────────────────────────────
  769. 18. 主逻辑:startAnswer()
  770. ───────────────────────────────────────────────────────────────────────*/
  771. function startAnswer() {
  772. logMsg('Start pressed.');
  773. const qDiv = getQuestionDiv();
  774. if (!qDiv) { logMsg('Question div not found'); return; }
  775. config.lastState = qDiv.innerHTML;
  776.  
  777. let userPrompt = 'HTML:\n' + qDiv.outerHTML + '\n';
  778. const latex = captureLatex(qDiv);
  779. if (latex) userPrompt += 'LaTeX:\n' + latex + '\n';
  780. else {
  781. const c64 = captureCanvas(qDiv);
  782. if (c64) userPrompt += 'Canvas image base64 attached.\n';
  783. }
  784.  
  785. UI.answerBox.style.display = 'none';
  786. UI.statusLine.textContent = langText[config.language].statusWaiting;
  787. startProgress();
  788.  
  789. const autoFillPrompt = `
  790. You are an IXL math solver with automation support.
  791. 1. Solve the problem.
  792. 2. Provide final answer inside <answer>...</answer>.
  793. 3. After a blank line, show steps in Markdown.
  794. 4. At end, include one \`\`\`javascript block to autofill the input.`;
  795.  
  796. const displayOnlyPrompt = `
  797. You are an IXL math solver.
  798. First return <answer>RESULT</answer> on its own line.
  799. Then a blank line, then solution steps in Markdown.`;
  800.  
  801. const messages = config.mode === 'autoFill'
  802. ? [{ role: 'system', content: autoFillPrompt }, { role: 'user', content: userPrompt }]
  803. : [{ role: 'system', content: displayOnlyPrompt }, { role: 'user', content: userPrompt }];
  804.  
  805. const payload = {
  806. model: config.selectedModel,
  807. messages: messages,
  808. stream: config.mode === 'displayOnly'
  809. };
  810. const conf = modelConfigs[config.selectedModel];
  811.  
  812. if (config.mode === 'displayOnly') {
  813. // SSE 流式
  814. let buffer = '';
  815. let answerDone = false;
  816. GM_xmlhttpRequest({
  817. method: 'POST',
  818. url: conf.apiBase,
  819. headers: {
  820. 'Content-Type': 'application/json',
  821. 'Authorization': 'Bearer ' + conf.apiKey,
  822. 'Accept': 'text/event-stream'
  823. },
  824. data: JSON.stringify(payload),
  825. onprogress: e => {
  826. const chunk = e.responseText.substring(e.loadedPrev || 0);
  827. e.loadedPrev = e.responseText.length;
  828. const lines = chunk.split('\n').filter(l => l.startsWith('data:'));
  829. lines.forEach(line => {
  830. const data = line.replace(/^data:\s*/, '').trim();
  831. if (data === '[DONE]') return;
  832. try {
  833. const json = JSON.parse(data);
  834. const delta = json.choices?.[0]?.delta?.content;
  835. if (!delta) return;
  836. buffer += delta;
  837. if (!answerDone) {
  838. const m = buffer.match(/<answer>[\s\S]*?<\/answer>/i);
  839. if (m) {
  840. answerDone = true;
  841. UI.answerContent.innerHTML = marked.parse(wrapLatex(m[0]));
  842. UI.answerBox.style.display = 'block';
  843. if (window.MathJax && typeof MathJax.typesetPromise === 'function') {
  844. MathJax.typesetPromise([UI.answerContent]).catch(() => {});
  845. }
  846. }
  847. }
  848. } catch {}
  849. });
  850. },
  851. onload: () => {
  852. stopProgress();
  853. const md = buffer.replace(/<answer>[\s\S]*?<\/answer>/i, '').trim();
  854. UI.stepsContent.innerHTML = marked.parse(wrapLatex(unescapeDollar(md)));
  855. if (window.MathJax && typeof MathJax.typesetPromise === 'function') {
  856. MathJax.typesetPromise([UI.stepsContent]).catch(() => {});
  857. }
  858. UI.statusLine.textContent = langText[config.language].statusDone;
  859. },
  860. onerror: err => {
  861. stopProgress();
  862. UI.statusLine.textContent = 'Stream error';
  863. logDump('SSE error', err);
  864. }
  865. });
  866. return;
  867. }
  868.  
  869. // AutoFill 模式
  870. GM_xmlhttpRequest({
  871. method: 'POST',
  872. url: conf.apiBase,
  873. headers: {
  874. 'Content-Type': 'application/json',
  875. 'Authorization': 'Bearer ' + conf.apiKey
  876. },
  877. data: JSON.stringify(payload),
  878. onload: resp => {
  879. stopProgress();
  880. try {
  881. const d = JSON.parse(resp.responseText);
  882. logDump('GPT raw', d);
  883. if (d.usage?.total_tokens) {
  884. config.totalTokens += d.usage.total_tokens;
  885. UI.tokenCount.textContent = langText[config.language].tokensLabel + config.totalTokens;
  886. }
  887. const out = d.choices[0].message.content;
  888. const ansMatch = out.match(/<answer>([\s\S]*?)<\/answer>/i);
  889. const ansTag = ansMatch ? ansMatch[0] : `<answer>${langText[config.language].missingAnswerTag}</answer>`;
  890. const steps = ansMatch ? out.replace(ansTag, '') : out;
  891. UI.answerContent.innerHTML = marked.parse(wrapLatex(ansTag));
  892. UI.stepsContent.innerHTML = marked.parse(wrapLatex(unescapeDollar(steps)));
  893. if (window.MathJax && typeof MathJax.typesetPromise === 'function') {
  894. MathJax.typesetPromise([UI.answerContent, UI.stepsContent]).catch(() => {});
  895. }
  896. const codeMatch = out.match(/```(?:javascript|js)?\s*([\s\S]*?)```/i);
  897. if (codeMatch && codeMatch[1]) {
  898. try {
  899. (new Function(codeMatch[1]))();
  900. } catch (e) {
  901. logDump('RunJS error', e);
  902. }
  903. if (config.autoSubmit) {
  904. const btn = document.querySelector('button.submit, button[class*=submit]');
  905. if (btn) btn.click();
  906. }
  907. } else {
  908. logMsg('No JS code block found');
  909. }
  910. UI.statusLine.textContent = langText[config.language].statusDone;
  911. } catch (e) {
  912. UI.statusLine.textContent = 'Parse error';
  913. logDump('Parse error', e);
  914. }
  915. },
  916. onerror: err => {
  917. stopProgress();
  918. UI.statusLine.textContent = langText[config.language].requestError + JSON.stringify(err);
  919. logDump('Request error', err);
  920. }
  921. });
  922. }
  923.  
  924. /*───────────────────────────────────────────────────────────────────────
  925. 19. 初始化
  926. ───────────────────────────────────────────────────────────────────────*/
  927. function initAll() {
  928. buildModelSelect();
  929. UI.txtApiKey.value = modelConfigs[config.selectedModel].apiKey;
  930. UI.txtApiBase.value = modelConfigs[config.selectedModel].apiBase;
  931. UI.modeSelect.value = config.mode;
  932. UI.autoSubmitRow.style.display = config.mode === 'autoFill' ? 'block' : 'none';
  933. UI.langSelect.value = config.language;
  934. updateManageLink();
  935. updateLangText();
  936. document.getElementById('settings-area').style.display = 'none';
  937. logMsg('IXL Auto Answer v9.1 loaded.');
  938. }
  939.  
  940. window.MathJax = {
  941. tex: { inlineMath: [['$', '$'], ['\\(', '\\)']] },
  942. svg: { fontCache: 'global' }
  943. };
  944.  
  945. initAll();
  946.  
  947. })();