// ==UserScript== // @name 学习通 AI 助教(每题解析) // @namespace xxt-ai-tutor // @version 1.2.0 // @description 在超星学习通 / 考试页面每道题旁注入「AI 解析」按钮,调用你配置的大模型接口,流式展示参考答案与讲解。 // @author Claude // @license MIT // @match *://*.chaoxing.com/* // @match *://*.xuexitong.com/* // @match *://*.chaoxing.com.cn/* // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_registerMenuCommand // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /* ========================================================= * 0. 基础守卫与常量 * ========================================================= */ if (document.documentElement.dataset.xxtAiTutor === '1') return; document.documentElement.dataset.xxtAiTutor = '1'; const STORAGE_KEY = 'xxt_ai_tutor_config_v1'; const CACHE_LIMIT = 200; const REQUEST_TIMEOUT = 180000; const QUESTION_SELECTOR = '.questionLi'; const QUESTION_FALLBACK_SELECTOR = '.TiMu, .question-item, .exam-question'; const TYPE_NAMES = [ '不定项选择题', '单选题', '多选题', '判断题', '填空题', '简答题', '论述题', '计算题', '材料题', '编程题', '名词解释', '匹配题', '连线题', '排序题', ]; const TYPE_RE = new RegExp('(' + TYPE_NAMES.join('|') + ')'); const THINKING_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']; const THINKING_LEVEL_LABELS = { low: 'low · 快速', medium: 'medium · 均衡', high: 'high · 深入(默认)', xhigh: 'xhigh · 超深入', max: 'max · 极限', ultra: 'ultra · 最深', }; const DEFAULT_SYSTEM_PROMPT = [ '你是一位严谨、耐心的大学课程助教,擅长把题目讲清楚,而不是只报答案。', '请用简体中文回答,并严格按下面的结构输出:', '', '【答案】<最终答案>', '【解析】<解题思路、关键知识点;选择题需逐项说明正确/错误原因>', '', '格式细则:', '- 单选题:字母 + 选项内容,例如:B. xxxx', '- 多选题:所有正确字母 + 对应内容,例如:A、C(A. xxx;C. xxx)', '- 判断题:只写「正确」或「错误」', '- 填空题:按空序输出,例如:空1:xxx;空2:yyy', '- 简答 / 论述 / 计算题:给出条目清晰、可直接参考的答案,关键步骤不能省略', '- 若题干或选项缺失、图片信息无法获取,请先说明你的假设,再给出最可能的答案', '- 不要输出与题目无关的客套话', ].join('\n'); const DEFAULT_CONFIG = { protocol: 'openai', endpoint: 'https://api.deepseek.com/v1/chat/completions', apiKey: '', model: 'deepseek-chat', temperature: 0.3, maxTokens: 2048, stream: true, markOptions: true, thinkingEnabled: false, thinkingLevel: 'high', systemPrompt: DEFAULT_SYSTEM_PROMPT, }; const PRESETS = { deepseek: { protocol: 'openai', endpoint: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', }, openai: { protocol: 'openai', endpoint: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o-mini', }, dashscope: { protocol: 'openai', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen-plus', }, moonshot: { protocol: 'openai', endpoint: 'https://api.moonshot.cn/v1/chat/completions', model: 'moonshot-v1-8k', }, zhipu: { protocol: 'openai', endpoint: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-4-flash', }, siliconflow: { protocol: 'openai', endpoint: 'https://api.siliconflow.cn/v1/chat/completions', model: 'Qwen/Qwen2.5-7B-Instruct', }, ollama: { protocol: 'openai', endpoint: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:7b', }, anthropic: { protocol: 'anthropic', endpoint: 'https://api.anthropic.com/v1/messages', model: 'claude-sonnet-5-5', }, }; const answerCache = new Map(); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /* ========================================================= * 1. 配置与通用工具 * ========================================================= */ function loadConfig() { try { const raw = GM_getValue(STORAGE_KEY, '{}'); const saved = typeof raw === 'string' ? JSON.parse(raw) : (raw || {}); const cfg = Object.assign({}, DEFAULT_CONFIG, saved); cfg.temperature = Number.isFinite(Number(cfg.temperature)) ? Number(cfg.temperature) : DEFAULT_CONFIG.temperature; cfg.maxTokens = Number(cfg.maxTokens) || DEFAULT_CONFIG.maxTokens; cfg.stream = cfg.stream !== false; cfg.markOptions = cfg.markOptions !== false; cfg.thinkingEnabled = cfg.thinkingEnabled === true || cfg.thinkingEnabled === 'true' || cfg.thinkingEnabled === 'on'; if (!THINKING_LEVELS.includes(cfg.thinkingLevel)) cfg.thinkingLevel = 'high'; return cfg; } catch (e) { return Object.assign({}, DEFAULT_CONFIG); } } function persistConfig(cfg) { GM_setValue(STORAGE_KEY, JSON.stringify(cfg)); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; function escapeHtml(value) { return String(value == null ? '' : value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]); } function truncate(text, length) { const s = String(text == null ? '' : text); return s.length > length ? s.slice(0, length) + '…' : s; } function hashString(text) { let hash = 5381; const s = String(text || ''); for (let i = 0; i < s.length; i += 1) { hash = ((hash << 5) + hash + s.charCodeAt(i)) | 0; } return (hash >>> 0).toString(36); } function textOf(el) { if (!el) return ''; let text = ''; try { text = el.innerText || ''; } catch (e) { text = ''; } if (!text) text = el.textContent || ''; return text.replace(/ /g, ' ').replace(/\r\n/g, '\n').trim(); } function isLocalEndpoint(url) { const s = String(url || '').trim(); return /^(https?:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)(:\d+)?(\/|$)/i.test(s); } function configProblems(cfg) { if (!cfg.endpoint) return '尚未配置 API 地址。'; if (!/^https?:\/\//i.test(cfg.endpoint)) return 'API 地址需要以 http:// 或 https:// 开头。'; if (!cfg.model) return '尚未填写模型名称。'; if (!cfg.apiKey && !isLocalEndpoint(cfg.endpoint)) return '尚未填写 API Key。'; return ''; } /* ========================================================= * 2. 页面题目识别 * ========================================================= */ function collectQuestions() { let selector = QUESTION_SELECTOR; let nodes = Array.from(document.querySelectorAll(selector)); if (!nodes.length) { selector = QUESTION_FALLBACK_SELECTOR; nodes = Array.from(document.querySelectorAll(selector)); } return nodes.filter((el) => { const parent = el.parentElement; if (parent && parent.closest(selector)) return false; // 跳过 display:none 的模板 / 隐藏副本,避免重复注入 return el.getClientRects().length > 0; }); } function normalizeOptionLabel(label, index) { let s = String(label || '').replace(/ /g, ' ').trim(); s = s.replace(/^[\s\[\((【]+/, '').replace(/[\s\]\))】]+$/, ''); s = s.replace(/[\s、..::))]/g, '').trim(); if (!s) s = String.fromCharCode(65 + index); return s; } function stripLabelPrefix(text, label) { if (!text || !label || text === label) return text; const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return text.replace(new RegExp('^' + escaped + '[\\s、..))::]+', 'i'), '').trim(); } function extractOptions(questionEl) { let nodes = Array.from(questionEl.querySelectorAll('.stem_answer .answerBg')); if (!nodes.length) nodes = Array.from(questionEl.querySelectorAll('.answerBg')); if (!nodes.length) { nodes = Array.from(questionEl.querySelectorAll('.stem_answer > li, .Zy_ulTop > li, .option-item, .optionItem')); } const out = []; const seenEl = new Set(); const seenLabel = new Set(); nodes.forEach((el, index) => { if (seenEl.has(el)) return; seenEl.add(el); if (el.closest('.xxt-ai-ui')) return; let label = textOf(el.querySelector('.num_option, .num_option_dx, .option-label, .optionLabel, .option-number')); let text = textOf(el.querySelector('.answer_p, .answerCon, .optionText, .option-text, .option-content')); if (!text) text = textOf(el); if (!label) { const letterMatch = text.match(/^([A-Za-z])\s*[、..))::]\s*/); const judgeMatch = text.match(/^(正确|错误|对|错)$/); if (letterMatch) label = letterMatch[1]; else if (judgeMatch) label = judgeMatch[1]; } label = normalizeOptionLabel(label, index); text = stripLabelPrefix(text, label); text = text.replace(/^[\s、..))::]+/, '').trim(); if (!text) return; if (seenLabel.has(label)) return; seenLabel.add(label); out.push({ label, text, el }); }); return out; } function extractInputs(questionEl) { const nodes = Array.from(questionEl.querySelectorAll( 'textarea, input[type="text"], input:not([type]), [contenteditable="true"], .edui-body-container' )); const out = []; const seen = new Set(); nodes.forEach((node) => { if (seen.has(node)) return; seen.add(node); if (node.closest('.xxt-ai-ui')) return; if (node.disabled || node.readOnly) return; if (!node.getClientRects().length) return; const placeholder = String( node.getAttribute('placeholder') || node.getAttribute('data-placeholder') || '' ).trim(); out.push({ placeholder }); }); return out; } function detectType(questionEl, stemRaw, options, inputs) { const typeEl = questionEl.querySelector( '.newZy_TItle .fl, .Zy_TItle .fl, .question-type, .questionType, .subject_type, .colorShallow, .mark_name' ); const typeText = textOf(typeEl) + ' ' + String(stemRaw || '').slice(0, 100); const match = typeText.match(TYPE_RE); if (match) return match[1]; const texts = options.map((o) => o.text.replace(/\s/g, '')); const trueRe = /^(对|正确|是|√|✓|true)/i; const falseRe = /^(错|错误|否|×|✗|false)/i; if (texts.length === 2) { if ((trueRe.test(texts[0]) && falseRe.test(texts[1])) || (falseRe.test(texts[0]) && trueRe.test(texts[1]))) { return '判断题'; } } if (options.length) { if (questionEl.querySelector('input[type="checkbox"]')) return '多选题'; return '单选题'; } if (inputs.length) { return /_{2,}|填空/.test(stemRaw) ? '填空题' : '简答题'; } return '其他'; } function cleanStem(raw, type) { let s = String(raw || '').replace(/ /g, ' ').trim(); const stripNumber = (value) => value.replace(/^\s*(?:第\s*)?\d+\s*[.、.]\s*/, ''); s = stripNumber(s); if (type && type !== '其他') { s = s.replace(new RegExp('^\\s*[\\[【((]?\\s*' + type + '\\s*[\\]】))]?\\s*'), ''); } s = s.replace(/^\s*[\[【((]\s*(?:不定项选择题|单选题|多选题|判断题|填空题|简答题|论述题|计算题|材料题|编程题|名词解释)\s*[\]】))]\s*/, ''); s = stripNumber(s); return s.trim(); } function extractStem(questionEl) { const selectors = [ '.newZy_TItle .clearfix', '.Zy_TItle .clearfix', '.question-title', '.questionTitle', '.subject_describe', '.newZy_TItle', '.Zy_TItle', '.mark_name', '.stem', ]; let best = ''; selectors.forEach((selector) => { const el = questionEl.querySelector(selector); if (!el) return; const text = textOf(el); if (text.length > best.length) best = text; }); return best; } function extractQuestion(questionEl) { const stemRaw = extractStem(questionEl); const options = extractOptions(questionEl); const inputs = extractInputs(questionEl); const type = detectType(questionEl, stemRaw, options, inputs); let stem = cleanStem(stemRaw, type); if (stem.length < 2) { const clone = questionEl.cloneNode(true); clone.querySelectorAll( '.stem_answer, .answerBg, .Zy_ulTop, textarea, input, [contenteditable="true"], .edui-body-container' ).forEach((node) => node.remove()); stem = cleanStem(textOf(clone), type); } const imageCount = Array.from(questionEl.querySelectorAll('img')) .filter((img) => !img.closest('.xxt-ai-ui')).length; const signature = hashString([ type, stem, options.map((o) => o.label + ':' + o.text).join('|'), inputs.length, imageCount, ].join('~')); return { el: questionEl, type, stem, options, inputs, imageCount, signature }; } function looksLikeQuestion(data) { if (data.options.length >= 2) return true; if (data.stem.length >= 4) return true; if (data.inputs.length >= 1 && data.stem.length >= 1) return true; return false; } /* ========================================================= * 3. 提示词与 API 调用 * ========================================================= */ function buildMessages(cfg, data) { const parts = []; parts.push('题目类型:' + (data.type || '未识别')); parts.push('题目:\n' + (data.stem || '(题干未能完整识别,请结合选项或作答框推断)')); if (data.options.length) { parts.push('选项:\n' + data.options.map((o) => o.label + '. ' + o.text).join('\n')); } if (data.inputs.length) { const placeholders = data.inputs.map((input, index) => { return '空' + (index + 1) + (input.placeholder ? '(提示:' + input.placeholder + ')' : ''); }).join(';'); parts.push('作答框:共 ' + data.inputs.length + ' 个' + (placeholders ? ':' + placeholders : '')); } if (data.imageCount) { parts.push('注意:题干/选项中含有 ' + data.imageCount + ' 张图片,文字中无法体现图片内容。'); } parts.push('请严格按系统要求的格式给出【答案】与【解析】。'); return [ { role: 'system', content: cfg.systemPrompt || DEFAULT_SYSTEM_PROMPT }, { role: 'user', content: parts.join('\n\n') }, ]; } function buildHeaders(cfg) { const headers = { 'Content-Type': 'application/json' }; if (cfg.protocol === 'anthropic') { if (cfg.apiKey) headers['x-api-key'] = cfg.apiKey; headers['anthropic-version'] = '2023-06-01'; headers['anthropic-dangerous-direct-browser-access'] = 'true'; } else if (cfg.apiKey) { headers.Authorization = 'Bearer ' + cfg.apiKey; } return headers; } function isDeepSeekEndpoint(url) { try { const host = new URL(String(url || '')).hostname.toLowerCase(); return host === 'deepseek.com' || host.endsWith('.deepseek.com'); } catch (e) { return /(^|\.)deepseek\.com([/:]|$)/i.test(String(url || '')); } } function applyThinkingParams(cfg, body) { const enabled = !!cfg.thinkingEnabled; const level = THINKING_LEVELS.includes(cfg.thinkingLevel) ? cfg.thinkingLevel : 'high'; if (cfg.protocol === 'anthropic') { if (enabled) { // 新版 Claude 用 adaptive thinking + output_config.effort; // Anthropic 没有 ultra 档位,按最高档 max 发送。 body.thinking = { type: 'adaptive' }; body.output_config = { effort: level === 'ultra' ? 'max' : level }; } else { body.thinking = { type: 'disabled' }; } return; } if (enabled) { body.reasoning_effort = level; // DeepSeek 默认开启思考,显式声明更清晰;其他 OpenAI 兼容服务不传 thinking 对象 if (isDeepSeekEndpoint(cfg.endpoint)) { body.thinking = { type: 'enabled' }; } return; } // DeepSeek 默认开启思考,关闭时必须显式传 disabled if (isDeepSeekEndpoint(cfg.endpoint)) { body.thinking = { type: 'disabled' }; } } function buildBody(cfg, messages) { const thinking = !!cfg.thinkingEnabled; if (cfg.protocol === 'anthropic') { const system = messages .filter((m) => m.role === 'system') .map((m) => m.content) .join('\n\n'); const msgs = messages .filter((m) => m.role !== 'system') .map((m) => ({ role: m.role, content: m.content })); const body = { model: cfg.model, max_tokens: cfg.maxTokens || 2048, system: system || undefined, messages: msgs, stream: !!cfg.stream, }; applyThinkingParams(cfg, body); // 开启思考后部分 Claude 模型不接受自定义 temperature if (!thinking) body.temperature = cfg.temperature; return body; } const body = { model: cfg.model, messages, max_tokens: cfg.maxTokens || undefined, stream: !!cfg.stream, }; applyThinkingParams(cfg, body); if (!thinking) body.temperature = cfg.temperature; return body; } function extractText(cfg, json) { if (!json) return ''; if (cfg.protocol === 'anthropic') { if (Array.isArray(json.content)) { return json.content .filter((block) => block && block.type === 'text') .map((block) => block.text || '') .join(''); } return ''; } const content = json.choices && json.choices[0] && json.choices[0].message ? json.choices[0].message.content : ''; if (Array.isArray(content)) { return content.map((part) => (part && (part.text || part.content)) || '').join(''); } return typeof content === 'string' ? content : ''; } function apiErrHint(json) { if (!json || !json.error) return ''; const message = typeof json.error === 'string' ? json.error : (json.error.message || ''); return message ? ':' + truncate(message, 200) : ''; } function gmRequest(cfg, url, headers, body) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('当前脚本管理器不支持 GM_xmlhttpRequest')); return; } GM_xmlhttpRequest({ method: 'POST', url, headers, data: JSON.stringify(body), timeout: REQUEST_TIMEOUT, onload(response) { if (response.status >= 200 && response.status < 300) { let json; try { json = JSON.parse(response.responseText); } catch (e) { reject(new Error('无法解析 API 返回的 JSON')); return; } const text = extractText(cfg, json); if (!text) { reject(new Error('API 返回内容为空' + apiErrHint(json))); return; } resolve(text); return; } reject(new Error( 'HTTP ' + response.status + (response.responseText ? ':' + truncate(response.responseText, 300) : '') )); }, onerror() { reject(new Error('网络请求失败(请检查 API 地址、网络或代理设置)')); }, ontimeout() { reject(new Error('请求超时')); }, }); }); } async function readSSE(cfg, response, onDelta) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let full = ''; const handleLine = (rawLine) => { const line = rawLine.replace(/\r$/, ''); if (!line.startsWith('data:')) return; const payload = line.slice(5).trim(); if (!payload || payload === '[DONE]') return; let event; try { event = JSON.parse(payload); } catch (e) { return; } if (event && event.error) { throw new Error(event.error.message || 'API 流式返回错误'); } let delta = ''; if (cfg.protocol === 'anthropic') { if (event.type === 'content_block_delta' && event.delta && event.delta.text) { delta = event.delta.text; } } else { const choice = event.choices && event.choices[0]; if (choice && choice.delta && typeof choice.delta.content === 'string') { delta = choice.delta.content; } else if (choice && choice.message && typeof choice.message.content === 'string' && !full) { delta = choice.message.content; } } if (delta) { full += delta; if (onDelta) onDelta(delta, full); } }; for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newlineIndex; while ((newlineIndex = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); handleLine(line); } } if (buffer.trim()) handleLine(buffer); return full; } async function fetchSSE(cfg, url, headers, body, onDelta, signal) { const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal, }); if (!response.ok) { let detail = ''; try { detail = await response.text(); } catch (e) { detail = ''; } throw new Error('HTTP ' + response.status + (detail ? ':' + truncate(detail, 300) : '')); } const contentType = (response.headers.get('content-type') || '').toLowerCase(); if (!response.body || contentType.includes('application/json')) { let json; try { json = await response.json(); } catch (e) { throw new Error('无法解析 API 返回的 JSON'); } const text = extractText(cfg, json); if (!text) throw new Error('API 返回内容为空' + apiErrHint(json)); if (onDelta) onDelta(text, text); return text; } return readSSE(cfg, response, onDelta); } async function apiRequest(cfg, messages, onDelta, signal) { const url = String(cfg.endpoint || '').trim(); if (!url) throw new Error('API 地址为空'); const headers = buildHeaders(cfg); const body = buildBody(cfg, messages); if (cfg.stream && typeof fetch === 'function') { try { return await fetchSSE(cfg, url, headers, body, onDelta, signal); } catch (error) { if (signal && signal.aborted) throw error; // CORS / 网络层失败时退回 GM_xmlhttpRequest(非流式,可跨域) if (!(error instanceof TypeError)) throw error; const text = await gmRequest(cfg, url, headers, Object.assign({}, body, { stream: false })); if (onDelta) onDelta(text, text); return text; } } const text = await gmRequest(cfg, url, headers, body); if (onDelta) onDelta(text, text); return text; } async function testConnection(cfg) { const testCfg = Object.assign({}, cfg, { stream: false, maxTokens: 32, thinkingEnabled: false }); return gmRequest(testCfg, testCfg.endpoint, buildHeaders(testCfg), buildBody(testCfg, [ { role: 'system', content: '你是连接测试助手。' }, { role: 'user', content: '请只回复:连接正常' }, ])); } /* ========================================================= * 4. 答案解析、渲染与选项标记 * ========================================================= */ function parseAnswer(text) { if (!text) return ''; const match = String(text).match(/【\s*答案\s*】\s*([\s\S]*?)(?=\n?\s*【\s*解析\s*】|$)/); if (match) return match[1].trim().replace(/^[::]\s*/, ''); const fallback = String(text).match(/(?:^|\n)\s*(?:\*\*)?答案(?:\*\*)?\s*[::]\s*([^\n]+)/); return fallback ? fallback[1].trim() : ''; } function parseChoiceLabels(answer, labels, multi) { const out = new Set(); const valid = new Set(labels.map((label) => String(label).trim().toUpperCase()).filter(Boolean)); if (!answer || !valid.size) return out; const addSequence = (part) => { const cleaned = String(part || '').replace(/^[^A-Za-z]+/, ''); const match = cleaned.match(/^([A-Z](?:\s*[、,,..\/和与]?\s*[A-Z]){0,15})(?![a-zA-Z])/); if (!match) return; const letters = match[1].toUpperCase().replace(/[^A-Z]/g, ''); for (const letter of letters) { if (valid.has(letter)) out.add(letter); } }; const segments = String(answer).split(/[\n;;。]/).slice(0, 12); if (multi) { segments.forEach(addSequence); } else { addSequence(segments[0] || ''); } return out; } function parseJudge(answer) { const head = String(answer || '').slice(0, 40); if (/(错误|不正确|不对|不是|否|×|✗|false)/i.test(head)) return false; if (/(正确|对|是|√|✓|true)/i.test(head)) return true; return null; } function isJudgeOptions(options) { if (!options || options.length !== 2) return false; const first = options[0].text.replace(/\s/g, ''); const second = options[1].text.replace(/\s/g, ''); const trueRe = /^(对|正确|是|√|✓|true)/i; const falseRe = /^(错|错误|否|×|✗|false)/i; return (trueRe.test(first) && falseRe.test(second)) || (falseRe.test(first) && trueRe.test(second)); } function clearOptionMarks(data) { data.options.forEach((option) => { option.el.classList.remove('xxt-ai-picked'); option.el.querySelectorAll('.xxt-ai-picked-badge').forEach((badge) => badge.remove()); }); } function markOptions(data, answer, type) { clearOptionMarks(data); if (!answer || !data.options.length) return; const picked = new Set(); const judge = type === '判断题' || isJudgeOptions(data.options); if (judge) { const truth = parseJudge(answer); if (truth !== null) { data.options.forEach((option) => { const text = option.text.replace(/\s/g, ''); const isTrue = /^(对|正确|是|√|✓|true)/i.test(text); const isFalse = /^(错|错误|否|×|✗|false)/i.test(text); if ((truth && isTrue) || (!truth && isFalse)) picked.add(option); }); } else { markByLetters(data, answer, picked); } } else { markByLetters(data, answer, picked); } picked.forEach((option) => { option.el.classList.add('xxt-ai-picked'); if (!option.el.querySelector('.xxt-ai-picked-badge')) { const badge = document.createElement('span'); badge.className = 'xxt-ai-picked-badge xxt-ai-ui'; badge.textContent = 'AI 推荐'; option.el.appendChild(badge); } }); } function markByLetters(data, answer, picked) { const multi = data.type === '多选题' || data.type === '不定项选择题'; const letters = parseChoiceLabels(answer, data.options.map((o) => o.label), multi); if (!letters.size) return; data.options.forEach((option) => { if (letters.has(String(option.label).toUpperCase())) picked.add(option); }); } function renderMarkdown(text) { const codeBlocks = []; let html = String(text || ''); html = html.replace(/```[^\n]*\n?([\s\S]*?)```/g, (match, code) => { codeBlocks.push(String(code).replace(/\n$/, '')); return '\u0000CODE' + (codeBlocks.length - 1) + '\u0000'; }); html = escapeHtml(html); html = html.replace(/【答案】/g, '【答案】'); html = html.replace(/【解析】/g, '【解析】'); html = html.replace(/^#{1,4}\s*(.+)$/gm, '
$1
'); html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); html = html.replace(/`([^`\n]+)`/g, '$1'); html = html.replace(/\n/g, '
'); html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) => { return '
' + escapeHtml(codeBlocks[Number(index)] || '') + '
'; }); return html; } /* ========================================================= * 5. 页面 UI * ========================================================= */ let fabWrap = null; let settingsMask = null; let toastTimer = null; function injectCSS() { const css = ` .xxt-ai-ui, .xxt-ai-ui * { box-sizing: border-box; } .xxt-ai-ui[hidden], .xxt-ai-ui [hidden] { display: none !important; } .xxt-ai-bar { display: flex; align-items: center; gap: 8px; justify-content: flex-end; margin: 0 0 6px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } .xxt-ai-type { margin-right: auto; padding: 1px 9px; border-radius: 999px; font-size: 12px; line-height: 20px; color: #4f5f82; background: #eef3ff; border: 1px solid #dde7ff; white-space: nowrap; } .xxt-ai-btn { display: inline-flex; align-items: center; gap: 5px; appearance: none; -webkit-appearance: none; border: 0; border-radius: 9px; padding: 6px 13px; font-size: 13px; line-height: 1; font-weight: 600; color: #fff; cursor: pointer; font-family: inherit; background: linear-gradient(135deg, #5b7cff, #8a5cff); box-shadow: 0 4px 12px rgba(91, 124, 255, .28); transition: transform .15s ease, box-shadow .15s ease, opacity .15s ease; } .xxt-ai-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(91, 124, 255, .36); } .xxt-ai-btn:active { transform: translateY(0); } .xxt-ai-btn:disabled { opacity: .72; cursor: default; transform: none; } .xxt-ai-running .xxt-ai-spark { display: inline-block; animation: xxt-ai-spin 1s linear infinite; } @keyframes xxt-ai-spin { to { transform: rotate(360deg); } } .xxt-ai-panel { margin: 10px 0 16px; border: 1px solid #dbe5ff; border-radius: 14px; background: #fff; box-shadow: 0 8px 28px rgba(64, 86, 165, .08); overflow: hidden; color: #22304d; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } .xxt-ai-panel-head { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: linear-gradient(180deg, #f6f8ff, #eef3ff); border-bottom: 1px solid #e3ebff; font-size: 13px; } .xxt-ai-panel-title { font-weight: 700; color: #33509a; white-space: nowrap; } .xxt-ai-status { min-width: 0; max-width: 42%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #7484a3; font-size: 12px; } .xxt-ai-status.running::after { content: ''; display: inline-block; width: 6px; height: 6px; margin-left: 5px; border-radius: 50%; background: #5b7cff; vertical-align: 1px; animation: xxt-ai-blink 1s ease-in-out infinite; } @keyframes xxt-ai-blink { 50% { opacity: .25; } } .xxt-ai-actions { margin-left: auto; display: flex; gap: 2px; } .xxt-ai-actions button { appearance: none; border: 0; background: transparent; color: #5b6d92; font-size: 12px; padding: 4px 7px; border-radius: 7px; cursor: pointer; font-family: inherit; } .xxt-ai-actions button:hover { background: #e3ebff; color: #2f4a8f; } .xxt-ai-answer { margin: 12px 12px 0; padding: 10px 12px; border: 1px solid #b7ebcf; border-radius: 11px; background: linear-gradient(180deg, #f0fdf6, #e9fbf1); } .xxt-ai-answer-label { margin-bottom: 3px; font-size: 11px; font-weight: 700; letter-spacing: .08em; color: #0f9f6e; } .xxt-ai-answer-text { font-size: 14px; line-height: 1.65; color: #0b5c44; white-space: pre-wrap; word-break: break-word; } .xxt-ai-body { padding: 12px; font-size: 13.5px; line-height: 1.8; color: #27344f; white-space: pre-wrap; word-break: break-word; } .xxt-ai-body.streaming { color: #42506e; } .xxt-ai-h { margin: 8px 0 4px; font-weight: 700; color: #223a74; } .xxt-ai-body strong { color: #1e3f8f; } .xxt-ai-body code { padding: 0 4px; border: 1px solid #e0e7f4; border-radius: 4px; background: #eef2fa; font-family: ui-monospace, Consolas, "Courier New", monospace; font-size: 12.5px; } .xxt-ai-code { margin: 8px 0; padding: 10px 12px; border-radius: 9px; background: #0f172a; color: #e2e8f0; font-family: ui-monospace, Consolas, "Courier New", monospace; font-size: 12.5px; line-height: 1.6; white-space: pre; overflow: auto; } .xxt-ai-tag { color: #4f66d9; font-weight: 700; } .xxt-ai-error { padding: 10px 12px; border: 1px solid #fecaca; border-radius: 10px; background: #fef2f2; color: #b91c1c; font-size: 13px; line-height: 1.6; } .xxt-ai-link { border: 0; background: transparent; color: #2563eb; cursor: pointer; font-size: 13px; text-decoration: underline; padding: 0 2px; font-family: inherit; } .xxt-ai-foot { padding: 0 12px 10px; color: #93a0b8; font-size: 11px; } .xxt-ai-picked { outline: 2px solid rgba(16, 185, 129, .45) !important; outline-offset: 2px; border-radius: 6px; } .xxt-ai-picked-badge { display: inline-block; margin-left: 8px; padding: 0 7px; border-radius: 999px; border: 1px solid #a7f3d0; background: #d1fae5; color: #047857; font-size: 11px; line-height: 18px; font-weight: 700; white-space: nowrap; vertical-align: middle; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } .xxt-ai-fab-wrap { position: fixed; right: 18px; bottom: 18px; z-index: 2147483000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } .xxt-ai-fab { position: relative; display: flex; align-items: center; justify-content: center; width: 46px; height: 46px; border: 0; border-radius: 50%; cursor: pointer; color: #fff; font-size: 14px; font-weight: 800; letter-spacing: .5px; background: linear-gradient(135deg, #5b7cff, #8a5cff); box-shadow: 0 8px 24px rgba(91, 124, 255, .42); transition: transform .15s ease; } .xxt-ai-fab:hover { transform: translateY(-2px) scale(1.03); } .xxt-ai-fab-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font-size: 10px; line-height: 18px; text-align: center; box-shadow: 0 2px 6px rgba(239, 68, 68, .4); } .xxt-ai-menu { position: absolute; right: 0; bottom: 58px; width: 236px; padding: 14px; border: 1px solid #e2e9f7; border-radius: 15px; background: rgba(255, 255, 255, .98); box-shadow: 0 18px 50px rgba(30, 50, 110, .18); backdrop-filter: blur(12px); } .xxt-ai-menu-title { font-size: 14px; font-weight: 700; color: #22345c; } .xxt-ai-menu-sub { margin-top: 3px; font-size: 12px; color: #7b89a6; } .xxt-ai-count { color: #4f66d9; font-weight: 700; } .xxt-ai-menu-progress { margin-top: 6px; font-size: 12px; color: #4f66d9; } .xxt-ai-menu-btn { width: 100%; margin-top: 10px; padding: 9px 12px; border-radius: 10px; border: 1px solid #dbe4f7; background: #fff; color: #33405e; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; transition: background .15s ease; } .xxt-ai-menu-btn:hover { background: #f2f6ff; } .xxt-ai-menu-btn.primary { border: 0; color: #fff; background: linear-gradient(135deg, #5b7cff, #8a5cff); } .xxt-ai-menu-btn.primary:hover { filter: brightness(1.05); } .xxt-ai-menu-tip { margin-top: 10px; color: #9aa6bd; font-size: 10.5px; line-height: 1.5; } .xxt-ai-mask { position: fixed; inset: 0; z-index: 2147483600; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(16, 24, 44, .5); } .xxt-ai-modal { width: min(620px, 100%); max-height: 90vh; overflow: auto; padding: 20px 22px; border-radius: 18px; background: #fff; box-shadow: 0 30px 80px rgba(10, 20, 50, .35); color: #22304d; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } .xxt-ai-modal-head { display: flex; align-items: flex-start; gap: 12px; } .xxt-ai-modal-title { font-size: 17px; font-weight: 800; color: #1d2c50; } .xxt-ai-modal-sub { margin-top: 4px; color: #7c89a4; font-size: 12px; line-height: 1.5; } .xxt-ai-x { margin-left: auto; width: 30px; height: 30px; flex: none; border: 0; border-radius: 9px; background: #f1f4fb; color: #64748b; font-size: 18px; cursor: pointer; } .xxt-ai-field { flex: 1; min-width: 0; margin-top: 13px; } .xxt-ai-field label { display: block; margin-bottom: 5px; font-size: 12px; font-weight: 600; color: #51607d; } .xxt-ai-field input, .xxt-ai-field select, .xxt-ai-field textarea { width: 100%; padding: 8px 10px; border: 1px solid #d8e0ef; border-radius: 9px; background: #fbfcff; color: #1f2b45; font-size: 13px; font-family: inherit; outline: none; } .xxt-ai-field input:focus, .xxt-ai-field select:focus, .xxt-ai-field textarea:focus { border-color: #7b93ff; box-shadow: 0 0 0 3px rgba(91, 124, 255, .14); } .xxt-ai-field textarea { min-height: 110px; resize: vertical; line-height: 1.6; } .xxt-ai-row { display: flex; gap: 10px; } .xxt-ai-checks { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 13px; } .xxt-ai-checks label { display: inline-flex; align-items: center; gap: 6px; color: #4b5a78; font-size: 12.5px; cursor: pointer; } .xxt-ai-checks input { accent-color: #5b7cff; } .xxt-ai-modal-foot { display: flex; align-items: center; gap: 8px; margin-top: 18px; padding-top: 14px; border-top: 1px solid #edf1f8; } .xxt-ai-test-status { flex: 1; min-width: 0; color: #7c89a4; font-size: 12px; line-height: 1.4; } .xxt-ai-test-status.ok { color: #0f9f6e; } .xxt-ai-test-status.err { color: #dc2626; } .xxt-ai-modal-foot button { padding: 8px 13px; border: 1px solid #d8e0ef; border-radius: 9px; background: #fff; color: #33405e; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; } .xxt-ai-modal-foot button:hover { background: #f5f8ff; } .xxt-ai-modal-foot button.primary { border: 0; color: #fff; background: linear-gradient(135deg, #5b7cff, #8a5cff); } .xxt-ai-modal-foot button:disabled { opacity: .6; cursor: default; } .xxt-ai-note { margin-top: 14px; padding: 10px 12px; border: 1px solid #e8eefb; border-radius: 10px; background: #f7f9ff; color: #7a87a3; font-size: 11.5px; line-height: 1.7; } .xxt-ai-toast { position: fixed; top: 18px; left: 50%; z-index: 2147483700; max-width: 80%; padding: 9px 16px; border-radius: 10px; background: #1f2b4d; color: #fff; font-size: 13px; box-shadow: 0 10px 30px rgba(15, 25, 60, .3); opacity: 0; pointer-events: none; transform: translate(-50%, -8px); transition: opacity .2s ease, transform .2s ease; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } .xxt-ai-toast.show { opacity: 1; transform: translate(-50%, 0); } .xxt-ai-toast[data-type="error"] { background: #b91c1c; } @media (max-width: 640px) { .xxt-ai-row { flex-direction: column; gap: 0; } .xxt-ai-menu { width: 204px; } } `; if (typeof GM_addStyle === 'function') { GM_addStyle(css); } else { const style = document.createElement('style'); style.textContent = css; (document.head || document.documentElement).appendChild(style); } } function toast(message, type) { let el = document.querySelector('.xxt-ai-toast'); if (!el) { el = document.createElement('div'); el.className = 'xxt-ai-toast xxt-ai-ui'; document.body.appendChild(el); } el.textContent = message; el.dataset.type = type || 'ok'; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 2600); } function ensureFab() { if (fabWrap && document.contains(fabWrap)) return; fabWrap = document.createElement('div'); fabWrap.className = 'xxt-ai-fab-wrap xxt-ai-ui'; fabWrap.innerHTML = ` `; document.body.appendChild(fabWrap); const fab = fabWrap.querySelector('.xxt-ai-fab'); const menu = fabWrap.querySelector('.xxt-ai-menu'); fab.addEventListener('click', (event) => { event.stopPropagation(); menu.hidden = !menu.hidden; }); fabWrap.querySelector('[data-act="batch"]').addEventListener('click', () => toggleBatch()); fabWrap.querySelector('[data-act="settings"]').addEventListener('click', () => { menu.hidden = true; openSettings(); }); } function getQuestionStates() { return collectQuestions() .map((el) => el.__xxtAiState) .filter(Boolean); } function updateFab() { const states = getQuestionStates(); if (!fabWrap) return; fabWrap.hidden = states.length === 0; const countEl = fabWrap.querySelector('.xxt-ai-count'); const badge = fabWrap.querySelector('.xxt-ai-fab-badge'); const countText = String(states.length); if (countEl && countEl.textContent !== countText) countEl.textContent = countText; if (badge) { badge.hidden = states.length === 0; const badgeText = states.length > 99 ? '99+' : countText; if (badge.textContent !== badgeText) badge.textContent = badgeText; } } function injectQuestionUI(data) { const questionEl = data.el; if (questionEl.__xxtAiState) return; if (questionEl.closest('.xxt-ai-ui')) return; const bar = document.createElement('div'); bar.className = 'xxt-ai-bar xxt-ai-ui'; const typeBadge = document.createElement('span'); typeBadge.className = 'xxt-ai-type'; typeBadge.textContent = data.type || '题目'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'xxt-ai-btn'; btn.innerHTML = '✦AI 解析'; bar.appendChild(typeBadge); bar.appendChild(btn); questionEl.insertBefore(bar, questionEl.firstChild); const panel = document.createElement('div'); panel.className = 'xxt-ai-panel xxt-ai-ui'; panel.hidden = true; panel.innerHTML = `
✦ AI 解析
AI 生成内容可能有误,请自行核对 · 仅供学习复习
`; questionEl.appendChild(panel); const state = { data, panel, btn, statusEl: panel.querySelector('.xxt-ai-status'), answerEl: panel.querySelector('.xxt-ai-answer'), answerTextEl: panel.querySelector('.xxt-ai-answer-text'), bodyEl: panel.querySelector('.xxt-ai-body'), running: false, done: false, full: '', controller: null, timedOut: false, }; questionEl.__xxtAiState = state; btn.addEventListener('click', () => { panel.hidden = false; if (!state.done && !state.running) { runAnalysis(state); setTimeout(() => { try { panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (e) { // 忽略旧浏览器 } }, 80); } }); panel.querySelector('[data-act="close"]').addEventListener('click', () => { panel.hidden = true; }); panel.querySelector('[data-act="retry"]').addEventListener('click', () => { if (!state.running) { panel.hidden = false; runAnalysis(state, true); } }); panel.querySelector('[data-act="settings"]').addEventListener('click', openSettings); panel.querySelector('[data-act="copy"]').addEventListener('click', () => copyState(state)); } function copyState(state) { const content = state.full; if (!content) { toast('还没有可复制的内容'); return; } const answer = state.answerTextEl.textContent || ''; const payload = answer ? '参考答案:' + answer + '\n\n' + content : content; const fallbackCopy = () => { try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(payload); toast('已复制'); return true; } } catch (e) { // 继续尝试 execCommand } try { const textarea = document.createElement('textarea'); textarea.value = payload; textarea.style.cssText = 'position:fixed;left:-9999px;top:0;'; document.body.appendChild(textarea); textarea.select(); const ok = document.execCommand('copy'); textarea.remove(); toast(ok ? '已复制' : '复制失败,请手动选择文本', ok ? 'ok' : 'error'); return ok; } catch (e) { toast('复制失败,请手动选择文本', 'error'); return false; } }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(payload).then(() => toast('已复制')).catch(fallbackCopy); } else { fallbackCopy(); } } /* ========================================================= * 6. 单题 / 批量解析 * ========================================================= */ async function runAnalysis(state, force) { if (state.running) return false; const cfg = loadConfig(); if (!force && answerCache.has(state.data.signature)) { state.panel.hidden = false; renderResult(state, answerCache.get(state.data.signature), cfg, true); return true; } const problem = configProblems(cfg); if (problem) { state.panel.hidden = false; state.done = false; state.answerEl.hidden = true; state.statusEl.textContent = '尚未配置'; state.statusEl.classList.remove('running'); state.bodyEl.classList.remove('streaming'); state.bodyEl.innerHTML = '
' + escapeHtml(problem) + '
'; const link = state.bodyEl.querySelector('[data-act="open-settings"]'); if (link) link.addEventListener('click', openSettings); return false; } state.panel.hidden = false; state.running = true; state.done = false; state.full = ''; state.timedOut = false; state.btn.disabled = true; state.btn.classList.add('xxt-ai-running'); state.btn.querySelector('.xxt-ai-btn-label').textContent = '解析中…'; state.statusEl.textContent = cfg.thinkingEnabled ? '思考中(' + cfg.thinkingLevel + ')…' : '正在连接模型…'; state.statusEl.classList.add('running'); state.answerEl.hidden = true; state.answerTextEl.textContent = ''; state.bodyEl.classList.remove('streaming'); state.bodyEl.textContent = ''; const messages = buildMessages(cfg, state.data); const controller = new AbortController(); state.controller = controller; const timer = setTimeout(() => { state.timedOut = true; controller.abort(); }, REQUEST_TIMEOUT); let rafPending = false; const onDelta = (delta, full) => { state.full = full; if (rafPending) return; rafPending = true; requestAnimationFrame(() => { rafPending = false; if (state.running) state.bodyEl.textContent = state.full; }); }; try { const text = await apiRequest(cfg, messages, onDelta, controller.signal); if (controller.signal.aborted) throw new Error('已停止'); if (!text || !text.trim()) throw new Error('模型返回内容为空'); renderResult(state, text, cfg, false); rememberAnswer(state.data.signature, text); return true; } catch (error) { if (controller.signal.aborted) { finishError(state, state.timedOut ? '请求超时(180 秒),请重试或更换模型' : '已停止解析'); } else { finishError(state, '请求失败:' + (error && error.message ? error.message : '未知错误')); } return false; } finally { clearTimeout(timer); state.controller = null; state.running = false; state.btn.disabled = false; state.btn.classList.remove('xxt-ai-running'); state.btn.querySelector('.xxt-ai-btn-label').textContent = 'AI 解析'; state.statusEl.classList.remove('running'); } } function rememberAnswer(signature, text) { answerCache.set(signature, text); if (answerCache.size > CACHE_LIMIT) { const firstKey = answerCache.keys().next().value; if (firstKey !== undefined) answerCache.delete(firstKey); } } function renderResult(state, text, cfg, fromCache) { state.full = text; state.done = true; state.running = false; state.bodyEl.classList.remove('streaming'); state.bodyEl.innerHTML = renderMarkdown(text); const answer = parseAnswer(text); if (answer) { state.answerEl.hidden = false; state.answerTextEl.textContent = answer; } else { state.answerEl.hidden = true; state.answerTextEl.textContent = ''; } state.statusEl.textContent = (fromCache ? '已缓存 · ' : '完成 · ') + cfg.model + (cfg.thinkingEnabled ? ' · 思考 ' + cfg.thinkingLevel : ''); if (cfg.markOptions) { markOptions(state.data, answer, state.data.type); } else { clearOptionMarks(state.data); } } function finishError(state, message) { state.done = false; state.running = false; state.statusEl.textContent = '请求失败'; state.statusEl.classList.remove('running'); state.bodyEl.classList.remove('streaming'); state.bodyEl.innerHTML = '
' + escapeHtml(message) + '
'; state.answerEl.hidden = true; state.answerTextEl.textContent = ''; } const batch = { running: false, stop: false, current: null, total: 0, done: 0, failed: 0, }; async function toggleBatch() { if (batch.running) { batch.stop = true; if (batch.current && batch.current.controller) batch.current.controller.abort(); updateBatchUI(); return; } const states = getQuestionStates(); if (!states.length) { toast('当前页面未识别到题目'); return; } const problem = configProblems(loadConfig()); if (problem) { toast(problem + ' 请先在「API 设置」中配置', 'error'); openSettings(); return; } if (states.length > 20) { let proceed = true; try { proceed = window.confirm( '本页识别到 ' + states.length + ' 道题,将逐题调用 API,可能产生较多费用。是否继续?' ); } catch (e) { proceed = true; } if (!proceed) return; } batch.running = true; batch.stop = false; batch.current = null; batch.total = states.length; batch.done = 0; batch.failed = 0; updateBatchUI(); for (const state of states) { if (batch.stop) break; if (state.done) { batch.done += 1; updateBatchUI(); continue; } batch.current = state; const ok = await runAnalysis(state); if (ok) batch.done += 1; else batch.failed += 1; updateBatchUI(); if (batch.stop) break; await sleep(400); } const stopped = batch.stop; batch.running = false; batch.stop = false; batch.current = null; updateBatchUI(); if (stopped) { toast('已停止:完成 ' + batch.done + ' / ' + batch.total + ' 题'); } else if (batch.failed) { toast('批量解析完成:成功 ' + batch.done + ' 题,失败 ' + batch.failed + ' 题', 'error'); } else { toast('批量解析完成:共 ' + batch.done + ' 题'); } } function updateBatchUI() { if (!fabWrap) return; const btn = fabWrap.querySelector('[data-act="batch"]'); const progress = fabWrap.querySelector('.xxt-ai-menu-progress'); if (!btn) return; if (batch.running) { btn.textContent = '停止解析'; if (progress) { progress.hidden = false; progress.textContent = '正在解析 ' + Math.min(batch.done + 1, batch.total) + ' / ' + batch.total; } } else { btn.textContent = '解析本页全部题目'; if (progress) progress.hidden = true; } } /* ========================================================= * 7. 设置面板 * ========================================================= */ function openSettings() { if (settingsMask) { if (document.contains(settingsMask)) return; if (settingsMask.__onKey) { document.removeEventListener('keydown', settingsMask.__onKey, true); } settingsMask = null; } const cfg = loadConfig(); settingsMask = document.createElement('div'); settingsMask.className = 'xxt-ai-mask xxt-ai-ui'; settingsMask.innerHTML = ` `; document.body.appendChild(settingsMask); applyConfigToForm(settingsMask, cfg); const close = () => closeSettings(); settingsMask.querySelector('.xxt-ai-x').addEventListener('click', close); settingsMask.addEventListener('click', (event) => { if (event.target === settingsMask) close(); }); settingsMask.__onKey = (event) => { if (event.key === 'Escape') close(); }; document.addEventListener('keydown', settingsMask.__onKey, true); settingsMask.querySelector('[data-k="preset"]').addEventListener('change', (event) => { const preset = PRESETS[event.target.value]; if (!preset) return; setFormValue(settingsMask, 'protocol', preset.protocol); setFormValue(settingsMask, 'endpoint', preset.endpoint); setFormValue(settingsMask, 'model', preset.model); }); const thinkingToggle = settingsMask.querySelector('[data-k="thinkingEnabled"]'); const thinkingLevelSelect = settingsMask.querySelector('[data-k="thinkingLevel"]'); const syncThinkingField = () => { const on = thinkingToggle.value === 'on'; thinkingLevelSelect.disabled = !on; const field = thinkingLevelSelect.closest('.xxt-ai-field'); if (field) field.style.opacity = on ? '1' : '.55'; }; thinkingToggle.addEventListener('change', syncThinkingField); syncThinkingField(); settingsMask.querySelector('[data-act="save"]').addEventListener('click', () => { const nextConfig = readSettingsForm(settingsMask); if (!nextConfig.endpoint) { toast('请填写 API 地址', 'error'); return; } if (!nextConfig.model) { toast('请填写模型名称', 'error'); return; } persistConfig(nextConfig); toast('设置已保存'); close(); }); settingsMask.querySelector('[data-act="test"]').addEventListener('click', async () => { const statusEl = settingsMask.querySelector('.xxt-ai-test-status'); const btn = settingsMask.querySelector('[data-act="test"]'); const testCfg = readSettingsForm(settingsMask); if (!testCfg.endpoint || !testCfg.model) { statusEl.textContent = '请先填写 API 地址与模型'; statusEl.className = 'xxt-ai-test-status err'; return; } statusEl.textContent = '测试中…'; statusEl.className = 'xxt-ai-test-status'; btn.disabled = true; try { const reply = await testConnection(testCfg); statusEl.textContent = '连接成功:' + truncate(reply.replace(/\s+/g, ' '), 36); statusEl.className = 'xxt-ai-test-status ok'; } catch (error) { statusEl.textContent = '连接失败:' + truncate(error.message || String(error), 120); statusEl.className = 'xxt-ai-test-status err'; } finally { btn.disabled = false; } }); settingsMask.querySelector('[data-act="reset"]').addEventListener('click', () => { applyConfigToForm(settingsMask, Object.assign({}, DEFAULT_CONFIG, { apiKey: '' })); syncThinkingField(); toast('已填入默认值,尚未保存'); }); } function closeSettings() { if (!settingsMask) return; if (settingsMask.__onKey) { document.removeEventListener('keydown', settingsMask.__onKey, true); } settingsMask.remove(); settingsMask = null; } function setFormValue(mask, key, value) { const el = mask.querySelector('[data-k="' + key + '"]'); if (!el) return; if (el.type === 'checkbox') el.checked = !!value; else el.value = value == null ? '' : String(value); } function applyConfigToForm(mask, cfg) { setFormValue(mask, 'protocol', cfg.protocol); setFormValue(mask, 'endpoint', cfg.endpoint); setFormValue(mask, 'apiKey', cfg.apiKey); setFormValue(mask, 'model', cfg.model); setFormValue(mask, 'temperature', cfg.temperature); setFormValue(mask, 'maxTokens', cfg.maxTokens); setFormValue(mask, 'stream', cfg.stream); setFormValue(mask, 'markOptions', cfg.markOptions); setFormValue(mask, 'thinkingEnabled', cfg.thinkingEnabled ? 'on' : 'off'); setFormValue(mask, 'thinkingLevel', cfg.thinkingLevel || 'high'); setFormValue(mask, 'systemPrompt', cfg.systemPrompt); setFormValue(mask, 'preset', ''); } function readSettingsForm(mask) { const val = (key) => { const el = mask.querySelector('[data-k="' + key + '"]'); return el ? String(el.value || '').trim() : ''; }; const checked = (key) => { const el = mask.querySelector('[data-k="' + key + '"]'); return !!(el && el.checked); }; const temperature = parseFloat(val('temperature')); const maxTokens = parseInt(val('maxTokens'), 10); return { protocol: val('protocol') || 'openai', endpoint: val('endpoint'), apiKey: val('apiKey'), model: val('model'), temperature: Number.isFinite(temperature) ? clamp(temperature, 0, 2) : DEFAULT_CONFIG.temperature, maxTokens: Number.isFinite(maxTokens) ? Math.max(256, maxTokens) : DEFAULT_CONFIG.maxTokens, stream: checked('stream'), markOptions: checked('markOptions'), thinkingEnabled: val('thinkingEnabled') === 'on', thinkingLevel: THINKING_LEVELS.includes(val('thinkingLevel')) ? val('thinkingLevel') : 'high', systemPrompt: val('systemPrompt') || DEFAULT_SYSTEM_PROMPT, }; } /* ========================================================= * 8. 扫描与初始化 * ========================================================= */ let scanTimer = null; function scheduleScan() { if (scanTimer) return; scanTimer = setTimeout(() => { scanTimer = null; scan(); }, 300); } function scan() { if (!document.body) return; collectQuestions().forEach((questionEl) => { if (questionEl.__xxtAiState) return; if (questionEl.closest('.xxt-ai-ui')) return; const data = extractQuestion(questionEl); if (!looksLikeQuestion(data)) return; injectQuestionUI(data); }); const states = getQuestionStates(); if (states.length) { ensureFab(); updateFab(); } else if (fabWrap) { updateFab(); } } function init() { if (!document.body) { document.addEventListener('DOMContentLoaded', init, { once: true }); return; } injectCSS(); // 页面未识别到题目 / 悬浮按钮被遮挡时,也能从油猴菜单进入设置 if (typeof GM_registerMenuCommand === 'function' && window.top === window.self) { try { GM_registerMenuCommand('⚙ 设置 API / 切换模型', openSettings); } catch (e) { // 某些管理器不支持菜单命令,忽略 } } document.addEventListener('click', (event) => { if (!fabWrap) return; const menu = fabWrap.querySelector('.xxt-ai-menu'); if (menu && !menu.hidden && !fabWrap.contains(event.target)) menu.hidden = true; }, true); scan(); const observer = new MutationObserver((mutations) => { const relevant = mutations.some((mutation) => { const target = mutation.target; if (target && target.nodeType === 1 && target.closest && target.closest('.xxt-ai-ui')) { return false; } const added = Array.from(mutation.addedNodes || []); if (added.length && added.every((node) => { return node.nodeType === 1 && node.classList && node.classList.contains('xxt-ai-ui'); })) { return false; } return true; }); if (relevant) scheduleScan(); }); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener('pageshow', scheduleScan); setInterval(() => { if (!document.hidden) scan(); }, 5000); } init(); })();