// ==UserScript==
// @name GROQ/KIMI/ZHIPU 分析 (精简版 - 自动切换)
// @namespace http://tampermonkey.net/
// @version 3.1
// @description 集成AI文本解释工具(GROQ/KIMI/ZHIPU),支持文本选择唤出AI按钮、滚动显示/隐藏按钮、智能缓存、离线模式和无障碍访问。新增AI分析失败时自动切换服务功能。
// @author FocusReader & 整合版 & GROQ/KIMI/ZHIPU (精简 by AI)
// @match http://*/*
// @match https://*/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setClipboard
// @connect api.groq.com
// @connect api.moonshot.cn
// @connect open.bigmodel.cn
// @connect ms-ra-forwarder-for-ifreetime-beta-two.vercel.app
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// --- 默认 AI 配置常量 ---
const AI_SERVICES = {
GROQ: {
name: 'Groq',
url: 'https://api.groq.com/openai/v1/chat/completions',
model: '', // 留空
apiKey: '', // 留空
},
KIMI: {
name: 'Kimi',
url: 'https://api.moonshot.cn/v1/chat/completions',
model: '', // 留空
apiKey: '', // 留空
},
ZHIPU: {
name: 'ChatGLM',
url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
model: '', // 留空
apiKey: '', // 留空
}
};
// **新增:AI 服务尝试顺序** (KIMI作为最后的选择,除非是用户默认选择)
const AI_SERVICE_ORDER = ['GROQ', 'ZHIPU', 'KIMI'];
// --- 用户可配置的 AI 设置 ---
let userSettings = {
activeService: GM_getValue('activeService', 'GROQ'), // 默认激活 Groq
groqApiKey: GM_getValue('groqApiKey', AI_SERVICES.GROQ.apiKey),
groqModel: GM_getValue('groqModel', AI_SERVICES.GROQ.model),
kimiApiKey: GM_getValue('kimiApiKey', AI_SERVICES.KIMI.apiKey),
kimiModel: GM_getValue('kimiModel', AI_SERVICES.KIMI.model),
zhipuApiKey: GM_getValue('zhipuApiKey', AI_SERVICES.ZHIPU.apiKey),
zhipuModel: GM_getValue('zhipuModel', AI_SERVICES.ZHIPU.model),
};
/**
* 根据服务键获取完整的配置信息
* @param {string} serviceKey - GROQ, KIMI, or ZHIPU
* @returns {object} 配置对象
*/
function getServiceConfig(serviceKey) {
const defaults = AI_SERVICES[serviceKey];
let config = {
key: '',
model: '',
name: defaults.name,
url: defaults.url,
serviceKey: serviceKey
};
if (serviceKey === 'GROQ') {
config.key = userSettings.groqApiKey;
config.model = userSettings.groqModel;
} else if (serviceKey === 'KIMI') {
config.key = userSettings.kimiApiKey;
config.model = userSettings.kimiModel;
} else if (serviceKey === 'ZHIPU') {
config.key = userSettings.zhipuApiKey;
config.model = userSettings.zhipuModel;
}
return config;
}
// 获取当前活动服务的配置(主要用于 UI 初始化和默认尝试)
function getActiveServiceConfig() {
return getServiceConfig(userSettings.activeService);
}
// **新增:获取下一个 AI 服务的 Key**
/**
* 获取当前服务失败后的下一个服务键
* @param {string} currentServiceKey - 当前失败的服务键
* @returns {string|null} 下一个服务键,如果没有更多服务则返回 null
*/
function getNextServiceKey(currentServiceKey) {
// 确保用户默认的服务是第一个尝试的
let order = [userSettings.activeService];
AI_SERVICE_ORDER.forEach(key => {
if (key !== userSettings.activeService) {
order.push(key);
}
});
const currentIndex = order.indexOf(currentServiceKey);
if (currentIndex !== -1 && currentIndex < order.length - 1) {
// 返回列表中的下一个服务
return order[currentIndex + 1];
}
return null; // 没有下一个服务
}
// --- AI 解释配置常量 (保持不变) ---
const TTS_URL = 'https://ms-ra-forwarder-for-ifreetime-2.vercel.app/api/aiyue?text=';
const VOICE = 'en-US-EricNeural';
const CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 7 天缓存有效期
const MAX_CACHE_SIZE = 100; // 最大缓存条目数
const MAX_TEXT_LENGTH = 1000; // AI 处理的最大文本长度
const DEBOUNCE_DELAY = 100; // 选中变更的防抖延迟
const instruction = `你是一个智能助手,请用中文分析下面的内容。请根据内容类型(单词或句子)按以下要求进行分析:
如果是**句子或段落**,请:
1. 给出难度等级(A1-C2)并解释
2. 核心语法结构分析
3. 准确翻译
4. 重点短语及例句和例句翻译
如果是**单词**,请:
1. 音标及发音提示
2. 详细释义及词性
3. 常用搭配和例句
4. 记忆技巧(如有)
用 **加粗** 标出重点内容,保持回答简洁实用。`;
// --- 全局状态管理 ---
let appState = {
aiLastSelection: '', // 存储触发 AI 弹窗的文本
isAiModalOpen: false,
isSettingsModalOpen: false, // 新增:设置模态框状态
isAiLoading: false,
networkStatus: navigator.onLine,
lastScrollY: window.scrollY
};
// --- 缓存管理器 (保持不变) ---
class CacheManager {
static getCache() {
try {
const cache = GM_getValue('aiExplainCache', '{}');
return JSON.parse(cache);
} catch {
return {};
}
}
static setCache(cache) {
try {
GM_setValue('aiExplainCache', JSON.stringify(cache));
} catch (e) {
console.warn('Cache save failed:', e);
}
}
static get(key) {
const cache = this.getCache();
const item = cache[key];
if (!item) return null;
if (Date.now() - item.timestamp > CACHE_EXPIRE_TIME) {
this.remove(key);
return null;
}
return item.data;
}
static set(key, data) {
const cache = this.getCache();
this.cleanup(cache);
cache[key] = {
data: data,
timestamp: Date.now()
};
this.setCache(cache);
}
static remove(key) {
const cache = this.getCache();
delete cache[key];
this.setCache(cache);
}
static cleanup(cache = null) {
if (!cache) cache = this.getCache();
const now = Date.now();
const keys = Object.keys(cache);
keys.forEach(key => {
if (now - cache[key].timestamp > CACHE_EXPIRE_TIME) {
delete cache[key];
}
});
const remainingKeys = Object.keys(cache);
if (remainingKeys.length > MAX_CACHE_SIZE) {
remainingKeys
.sort((a, b) => cache[a].timestamp - cache[b].timestamp)
.slice(0, remainingKeys.length - MAX_CACHE_SIZE)
.forEach(key => delete cache[key]);
}
this.setCache(cache);
}
static getMostRecent() {
const cache = this.getCache();
let mostRecentKey = null;
let mostRecentTimestamp = 0;
for (const key in cache) {
if (cache.hasOwnProperty(key)) {
const item = cache[key];
if (item.timestamp > mostRecentTimestamp && (Date.now() - item.timestamp <= CACHE_EXPIRE_TIME)) {
mostRecentTimestamp = item.timestamp;
mostRecentKey = key;
}
}
}
return mostRecentKey ? { text: mostRecentKey, data: cache[mostRecentKey].data } : null;
}
}
// --- 工具函数 (保持不变) ---
const utils = {
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
},
sanitizeText(text) {
return text.trim().substring(0, MAX_TEXT_LENGTH);
},
isValidText(text) {
return text && text.trim().length > 0 && text.trim().length <= MAX_TEXT_LENGTH;
},
vibrate(pattern = [50]) {
if (navigator.vibrate) {
navigator.vibrate(pattern);
}
},
showToast(message, duration = 2000) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: white;
padding: 10px 20px;
border-radius: 20px;
font-size: 14px;
z-index: 10003;
animation: fadeInOut ${duration}ms ease-in-out;
`;
if (!document.getElementById('toast-style')) {
const style = document.createElement('style');
style.id = 'toast-style';
style.textContent = `
@keyframes fadeInOut {
0%, 100% { opacity: 0; transform: translateX(-50%) translateY(-20px); }
10%, 90% { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
};
// --- 样式 (新增配置模态框样式) ---
GM_addStyle(`
/* Floating AI Button */
#floatingAiButton {
position: fixed;
right: 15px;
top: 50%;
transform: translateY(-70%);
background: rgba(0, 122, 255, 0.85);
color: white;
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
font-size: 20px;
display: none; /* 默认隐藏 */
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 9999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transition: opacity 0.3s ease, transform 0.3s ease;
}
#floatingAiButton:hover {
background: rgba(0, 122, 255, 1);
}
#floatingAiButton:active {
transform: translateY(-70%) scale(0.95);
}
/* Modal Base Styles */
.ai-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
display: none; /* Hidden by default */
align-items: flex-start;
justify-content: center;
z-index: 10002;
padding: env(safe-area-inset-top, 20px) 10px 10px 10px;
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.ai-modal-content {
background: #2c2c2c;
color: #fff;
border-radius: 16px;
width: 100%;
max-width: 500px;
position: relative;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
margin: 20px 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ai-modal-header {
background: #333;
padding: 15px 20px;
border-bottom: 1px solid #444;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.ai-modal-body {
padding: 20px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
flex-grow: 1;
}
.ai-modal-footer {
background: #333;
padding: 15px 20px;
border-top: 1px solid #444;
display: flex;
gap: 10px;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: flex-end; /* 右对齐按钮 */
align-items: center;
}
.modal-btn {
padding: 10px 15px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.modal-btn:active {
transform: scale(0.95);
}
/* Specific AI Modal Styles */
#aiClose { background: #666; color: white; }
#aiCopy { background: #4CAF50; color: white; }
#aiPlay { background: #2196F3; color: white; }
#aiSource { font-size: 12px; color: #ccc; margin-bottom: 10px; flex-basis: 100%; text-align: center; }
#aiText { font-weight: bold; margin-bottom: 15px; padding: 10px; background: #3a3a3a; border-radius: 8px; border-left: 4px solid #4CAF50; word-break: break-word; font-size: 16px; }
#aiResult { white-space: pre-wrap; line-height: 1.6; word-break: break-word; font-size: 15px; }
#aiResult strong { color: #ffd700; font-weight: 600; }
/* Loading Indicator */
#loadingIndicator { display: flex; align-items: center; gap: 10px; color: #999; }
.loading-spinner { width: 20px; height: 20px; border: 2px solid #333; border-top: 2px solid #4CAF50; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* Settings Modal Styles */
.settings-group { margin-bottom: 20px; border: 1px solid #444; padding: 15px; border-radius: 8px; }
.settings-group label { display: block; margin-bottom: 8px; font-weight: bold; color: #fff; font-size: 14px; }
.settings-group input[type="text"] {
width: 100%;
padding: 10px;
margin-top: 4px;
margin-bottom: 10px;
border: 1px solid #555;
border-radius: 5px;
background: #3a3a3a;
color: #fff;
box-sizing: border-box;
}
.settings-group input:focus { border-color: #2196F3; outline: none; }
.radio-container { display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; }
.radio-container input[type="radio"] { margin-right: 5px; accent-color: #4CAF50; }
.radio-container label { font-weight: normal; }
#saveSettingsBtn { background: #4CAF50; color: white; }
#closeSettingsBtn { background: #666; color: white; }
/* General */
#networkStatus { position: fixed; top: env(safe-area-inset-top, 10px); left: 50%; transform: translateX(-50%); background: #f44336; color: white; padding: 8px 16px; border-radius: 20px; font-size: 12px; z-index: 10001; display: none; }
/* Mobile specific optimizations */
@media (max-width: 480px) {
.ai-modal-content { margin: 10px 0; border-radius: 12px; }
.ai-modal-header, .ai-modal-footer { padding: 12px 15px; }
.ai-modal-body { padding: 15px; }
.modal-btn { padding: 12px 8px; font-size: 13px; min-width: unset; }
#aiText { font-size: 15px; padding: 8px; }
#aiResult { font-size: 14px; }
#floatingAiButton { right: 10px; width: 44px; height: 44px; font-size: 18px; }
.settings-group input[type="text"] { font-size: 14px; }
}
`);
// --- UI 元素 (创建一次) ---
let uiElements = {};
function createCommonUI() {
// AI Modal
const aiModal = document.createElement('div');
aiModal.id = 'aiModal';
aiModal.className = 'ai-modal-overlay';
aiModal.setAttribute('role', 'dialog');
aiModal.setAttribute('aria-modal', 'true');
aiModal.setAttribute('aria-labelledby', 'aiText');
const currentConfig = getActiveServiceConfig();
const currentModelDisplay = currentConfig.model || '未配置';
aiModal.innerHTML = `
<div id="aiModalContent" class="ai-modal-content">
<div id="aiModalHeader" class="ai-modal-header">
<h3 style="margin: 0; font-size: 16px;">📖 AI解释</h3>
<div id="loadingIndicator" style="display: none;">
<div class="loading-spinner"></div>
<span>分析中...</span>
</div>
</div>
<div id="aiModalBody" class="ai-modal-body">
<div id="aiText" aria-live="polite"></div>
<div id="aiResult" aria-live="polite"></div>
</div>
<div id="aiModalFooter" class="ai-modal-footer">
<div id="aiSource">来源:${currentConfig.name} (${currentModelDisplay})</div>
<button id="aiPlay" class="modal-btn" aria-label="朗读文本">🔊 朗读</button>
<button id="aiCopy" class="modal-btn" aria-label="复制结果">
<span id="aiCopyText">📋 复制</span>
</button>
<button id="aiClose" class="modal-btn" aria-label="关闭对话框">❌ 关闭</button>
</div>
</div>
`;
document.body.appendChild(aiModal);
// Settings Modal (新增)
const settingsModal = document.createElement('div');
settingsModal.id = 'settingsModal';
settingsModal.className = 'ai-modal-overlay';
settingsModal.setAttribute('role', 'dialog');
settingsModal.setAttribute('aria-modal', 'true');
settingsModal.setAttribute('aria-labelledby', 'settingsHeader');
settingsModal.style.zIndex = '10004'; // 确保在 AI Modal 上方
settingsModal.innerHTML = `
<div id="settingsModalContent" class="ai-modal-content" style="max-height: calc(100vh - 40px);">
<div id="settingsHeader" class="ai-modal-header">
<h3 style="margin: 0; font-size: 18px;">⚙️ AI 设置 (GROQ/KIMI/ZHIPU)</h3>
</div>
<div id="settingsBody" class="ai-modal-body">
<div class="settings-group">
<label>选择当前活动的 AI 服务 (分析失败时,将按 Groq -> 智谱 -> Kimi 顺序尝试):</label>
<div class="radio-container">
<input type="radio" id="radioGroq" name="activeService" value="GROQ" ${userSettings.activeService === 'GROQ' ? 'checked' : ''}>
<label for="radioGroq">${AI_SERVICES.GROQ.name}</label>
<input type="radio" id="radioKimi" name="activeService" value="KIMI" ${userSettings.activeService === 'KIMI' ? 'checked' : ''}>
<label for="radioKimi">${AI_SERVICES.KIMI.name}</label>
<input type="radio" id="radioZhipu" name="activeService" value="ZHIPU" ${userSettings.activeService === 'ZHIPU' ? 'checked' : ''}>
<label for="radioZhipu">${AI_SERVICES.ZHIPU.name}</label>
</div>
</div>
<div id="groqSettings" class="settings-group" style="display: ${userSettings.activeService === 'GROQ' ? 'block' : 'none'};">
<label for="groqApiKey">Groq API Key (必填):</label>
<input type="text" id="groqApiKey" value="${userSettings.groqApiKey}" placeholder="sk-..." autocomplete="off">
<label for="groqModel">Groq Model (必填):</label>
<input type="text" id="groqModel" value="${userSettings.groqModel}" placeholder="例如: llama3-8b-8192" autocomplete="off">
<small style="color:#aaa;">API URL: ${AI_SERVICES.GROQ.url}</small>
</div>
<div id="kimiSettings" class="settings-group" style="display: ${userSettings.activeService === 'KIMI' ? 'block' : 'none'};">
<label for="kimiApiKey">KIMI API Key (必填):</label>
<input type="text" id="kimiApiKey" value="${userSettings.kimiApiKey}" placeholder="sk-..." autocomplete="off">
<label for="kimiModel">KIMI Model (必填):</label>
<input type="text" id="kimiModel" value="${userSettings.kimiModel}" placeholder="例如: moonshot-v1-8k" autocomplete="off">
<small style="color:#aaa;">API URL: ${AI_SERVICES.KIMI.url}</small>
</div>
<div id="zhipuSettings" class="settings-group" style="display: ${userSettings.activeService === 'ZHIPU' ? 'block' : 'none'};">
<label for="zhipuApiKey">智谱 API Key (必填):</label>
<input type="text" id="zhipuApiKey" value="${userSettings.zhipuApiKey}" placeholder="..." autocomplete="off">
<label for="zhipuModel">智谱 Model (必填):</label>
<input type="text" id="zhipuModel" value="${userSettings.zhipuModel}" placeholder="例如: glm-4" autocomplete="off">
<small style="color:#aaa;">API URL: ${AI_SERVICES.ZHIPU.url}</small>
</div>
</div>
<div id="settingsFooter" class="ai-modal-footer">
<button id="saveSettingsBtn" class="modal-btn">💾 保存并应用</button>
<button id="closeSettingsBtn" class="modal-btn">❌ 关闭</button>
</div>
</div>
`;
document.body.appendChild(settingsModal);
// Network status indicator
const networkStatus = document.createElement('div');
networkStatus.id = 'networkStatus';
networkStatus.textContent = '📡 网络离线';
document.body.appendChild(networkStatus);
// Floating AI button
const floatingAiButton = document.createElement('button');
floatingAiButton.id = 'floatingAiButton';
floatingAiButton.title = 'AI解释';
floatingAiButton.innerHTML = '💡';
document.body.appendChild(floatingAiButton);
uiElements = { aiModal, settingsModal, networkStatus, floatingAiButton };
}
// --- 设置模态框控制器 (保持不变) ---
const settingsModalController = {
open() {
// 确保 AI 模态框已关闭
if (appState.isAiModalOpen) aiModalController.close();
appState.isSettingsModalOpen = true;
uiElements.settingsModal.style.display = 'flex';
document.body.style.overflow = 'hidden';
uiElements.floatingAiButton.style.display = 'none';
this.updateView();
document.getElementById('saveSettingsBtn').focus();
},
close() {
appState.isSettingsModalOpen = false;
uiElements.settingsModal.style.display = 'none';
document.body.style.overflow = '';
updateFloatingAiButtonVisibility(); // 恢复浮动按钮逻辑
},
updateView() {
const active = userSettings.activeService;
document.getElementById('radioGroq').checked = active === 'GROQ';
document.getElementById('radioKimi').checked = active === 'KIMI';
document.getElementById('radioZhipu').checked = active === 'ZHIPU';
document.getElementById('groqSettings').style.display = active === 'GROQ' ? 'block' : 'none';
document.getElementById('kimiSettings').style.display = active === 'KIMI' ? 'block' : 'none';
document.getElementById('zhipuSettings').style.display = active === 'ZHIPU' ? 'block' : 'none';
},
save() {
// 读取 UI 上的值
const newActiveService = document.querySelector('input[name="activeService"]:checked').value;
const newGroqKey = document.getElementById('groqApiKey').value.trim();
const newGroqModel = document.getElementById('groqModel').value.trim();
const newKimiKey = document.getElementById('kimiApiKey').value.trim();
const newKimiModel = document.getElementById('kimiModel').value.trim();
const newZhipuKey = document.getElementById('zhipuApiKey').value.trim();
const newZhipuModel = document.getElementById('zhipuModel').value.trim();
// 简单的必填项检查
let checkFailed = false;
if (newActiveService === 'GROQ' && (!newGroqKey || !newGroqModel)) {
utils.showToast('⚠️ Groq API Key 或 Model 不能为空。');
checkFailed = true;
}
if (newActiveService === 'KIMI' && (!newKimiKey || !newKimiModel)) {
utils.showToast('⚠️ KIMI API Key 或 Model 不能为空。');
checkFailed = true;
}
if (newActiveService === 'ZHIPU' && (!newZhipuKey || !newZhipuModel)) {
utils.showToast('⚠️ 智谱 API Key 或 Model 不能为空。');
checkFailed = true;
}
if (checkFailed) return;
// 更新内部状态
userSettings.activeService = newActiveService;
userSettings.groqApiKey = newGroqKey;
userSettings.groqModel = newGroqModel;
userSettings.kimiApiKey = newKimiKey;
userSettings.kimiModel = newKimiModel;
userSettings.zhipuApiKey = newZhipuKey;
userSettings.zhipuModel = newZhipuModel;
// 存储到 Tampermonkey
GM_setValue('activeService', newActiveService);
GM_setValue('groqApiKey', newGroqKey);
GM_setValue('groqModel', newGroqModel);
GM_setValue('kimiApiKey', newKimiKey);
GM_setValue('kimiModel', newKimiModel);
GM_setValue('zhipuApiKey', newZhipuKey);
GM_setValue('zhipuModel', newZhipuModel);
// 更新 AI Modal 的来源显示
const currentConfig = getActiveServiceConfig();
document.getElementById('aiSource').textContent = `来源:${currentConfig.name} (${currentConfig.model || '未配置'})`;
this.close();
utils.showToast('✅ AI设置已保存并应用。'); // 首次反馈
}
};
// --- 网络状态监控 (保持不变) ---
function setupNetworkMonitoring() {
const updateNetworkStatus = () => {
appState.networkStatus = navigator.onLine;
uiElements.networkStatus.style.display = appState.networkStatus ? 'none' : 'block';
if (!appState.networkStatus) {
utils.showToast('📡 网络连接中断,将使用缓存数据');
}
};
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
updateNetworkStatus();
}
// --- AI 模态框控制 (仅修改关闭时的逻辑) ---
const aiModalController = {
open(text) {
appState.isAiModalOpen = true;
uiElements.aiModal.style.display = 'flex';
document.body.style.overflow = 'hidden'; // 防止主页面滚动
uiElements.floatingAiButton.style.display = 'none';
document.getElementById('aiText').textContent = text;
const closeBtn = document.getElementById('aiClose');
if (closeBtn) closeBtn.focus();
utils.vibrate([50, 50, 100]);
},
close() {
appState.isAiModalOpen = false;
uiElements.aiModal.style.display = 'none';
document.body.style.overflow = ''; // 恢复主页面滚动
// 关闭模态框后重新评估浮动按钮的可见性
updateFloatingAiButtonVisibility();
},
/**
* 更新模态框内容和来源显示
* @param {string} text - 输入文本
* @param {string} result - AI结果或错误信息
* @param {object} config - 成功提供服务的配置对象 (可选, 成功时使用)
*/
updateContent(text, result, config = null) {
const aiText = document.getElementById('aiText');
const aiResult = document.getElementById('aiResult');
const aiSource = document.getElementById('aiSource');
if (aiText) aiText.textContent = text;
if (aiResult) {
if (typeof result === 'string') {
// 替换 markdown **加粗** 为 <strong>
aiResult.innerHTML = result.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
} else {
aiResult.textContent = result;
}
}
if (aiSource && config) {
aiSource.textContent = `来源:${config.name} (${config.model || '未配置'})`;
} else if (aiSource && result.includes('(缓存)')) {
// 如果是缓存,保留缓存标记
} else if (aiSource && result.includes('❌')) {
// 如果是错误信息,不更新来源,或者显示当前尝试的服务
const currentConfig = getActiveServiceConfig();
aiSource.textContent = `来源:${currentConfig.name} (尝试失败/错误)`;
}
},
setLoading(isLoading) {
appState.isAiLoading = isLoading;
const loadingIndicator = document.getElementById('loadingIndicator');
if (loadingIndicator) {
loadingIndicator.style.display = isLoading ? 'flex' : 'none';
}
}
};
// --- AI 请求处理器 (修改为使用当前活动配置并支持切换) ---
/**
* 尝试使用一个 AI 服务获取解释
* @param {string} text - 待解释的文本
* @param {object} config - 当前尝试的 AI 服务配置
* @returns {Promise<string>} 成功的解释结果
*/
async function attemptFetch(text, config) {
const { url, key, model, name, serviceKey } = config;
// 检查配置
if (!key || !model || key.length < 5) {
throw new Error(`[${name}] 配置无效 (API Key 或 Model 缺失/过短)`);
}
aiModalController.updateContent(text, `🤖 尝试连接 ${name} (${model}) 进行分析...`);
const response = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`[${name}] 请求超时 (120秒)`));
}, 120000);
GM_xmlhttpRequest({
method: "POST",
url: url,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${key}`
},
data: JSON.stringify({
model: model,
messages: [
{ role: "system", content: instruction },
{ role: "user", content: `请分析:\n"${text}"` }
]
}),
onload: (res) => {
clearTimeout(timeout);
resolve(res);
},
onerror: (err) => {
clearTimeout(timeout);
reject(new Error(`[${name}] 网络错误或连接失败`));
}
});
});
let reply;
try {
const jsonResponse = JSON.parse(response.responseText);
// 检查 API 错误
if (jsonResponse.error) {
const errorMsg = jsonResponse.error.message || '未知错误';
throw new Error(`[${name}] API 错误: ${errorMsg}`);
}
reply = jsonResponse.choices?.[0]?.message?.content || null;
} catch (e) {
console.error(`[${name}] Error parsing AI response:`, e, response.responseText);
throw new Error(`[${name}] 无效的响应格式或 API 错误`);
}
if (!reply || !reply.trim()) {
throw new Error(`[${name}] AI 返回空内容`);
}
// 成功返回配置和结果
return { reply, config };
}
/**
* 循环尝试 AI 服务直到成功
* @param {string} text - 待解释的文本
*/
async function fetchAIExplanation(text) {
aiModalController.setLoading(true);
let serviceKeyToTry = userSettings.activeService; // 从用户默认选择的服务开始
let attemptsMade = 0;
let finalError = '';
while (serviceKeyToTry) {
attemptsMade++;
const config = getServiceConfig(serviceKeyToTry);
try {
const { reply, config: successConfig } = await attemptFetch(text, config);
// 成功
CacheManager.set(text, reply);
aiModalController.updateContent(text, reply, successConfig);
utils.showToast(`✅ ${successConfig.name} 解释完成`);
aiModalController.setLoading(false);
return; // 退出循环
} catch (error) {
console.warn(`AI service failed on attempt ${attemptsMade} (${config.name}):`, error.message);
finalError = error.message;
// 尝试下一个服务
serviceKeyToTry = getNextServiceKey(serviceKeyToTry);
if (!serviceKeyToTry) {
// 没有更多服务了
break;
}
utils.showToast(`⚠️ ${config.name} 失败,自动切换至 ${getServiceConfig(serviceKeyToTry).name}`);
// 更新模态框状态,显示正在切换
aiModalController.updateContent(text, `${error.message}\n\n正在自动切换到下一个 AI 服务...`);
}
}
// 所有尝试都失败了
const allFailedMessage = `❌ 所有 AI 服务尝试失败。最后错误:${finalError}。请检查API密钥、模型配置和网络连接。`;
aiModalController.updateContent(text, allFailedMessage);
utils.showToast('❌ 所有 AI 服务请求失败');
aiModalController.setLoading(false);
}
// 统一的 AI 解释触发逻辑 (更新来源显示)
async function triggerAIExplanation() {
const selection = window.getSelection();
let text = utils.sanitizeText(selection.toString());
const currentConfig = getActiveServiceConfig();
const currentModelDisplay = currentConfig.model || '未配置';
if (!utils.isValidText(text)) {
const recentCache = CacheManager.getMostRecent();
if (recentCache) {
appState.aiLastSelection = recentCache.text;
aiModalController.open(recentCache.text);
aiModalController.updateContent(recentCache.text, recentCache.data);
document.getElementById('aiSource').textContent = `来源:最近一次 (缓存)`;
utils.showToast('📋 显示最近一次解释');
} else {
utils.showToast('请先选择需要AI解释的文本,或无最近解释内容');
}
return;
}
appState.aiLastSelection = text;
aiModalController.open(text);
aiModalController.updateContent(text, '正在加载...');
document.getElementById('aiSource').textContent = `来源:${currentConfig.name} (${currentModelDisplay})`;
const cached = CacheManager.get(text);
if (cached) {
aiModalController.updateContent(text, cached);
document.getElementById('aiSource').textContent = `来源:${currentConfig.name} (缓存)`;
utils.showToast('📋 使用缓存数据');
} else if (appState.networkStatus) {
aiModalController.updateContent(text, `🤖 正在开始 ${currentConfig.name} 分析...`);
await fetchAIExplanation(text);
} else {
aiModalController.updateContent(text, '📡 网络离线,无法获取新的解释。');
}
}
// --- 全局事件监听器 ---
function setupGlobalEventListeners() {
// AI modal event listeners
document.getElementById('aiClose').addEventListener('click', aiModalController.close);
document.getElementById('aiCopy').addEventListener('click', () => {
const result = document.getElementById('aiResult');
const textToCopy = result ? result.innerText : '';
if (textToCopy) {
GM_setClipboard(textToCopy);
const copyBtnSpan = document.getElementById('aiCopyText');
if (copyBtnSpan) {
copyBtnSpan.textContent = '✅ 已复制';
setTimeout(() => {
if (copyBtnSpan) copyBtnSpan.textContent = '📋 复制';
}, 1500);
}
utils.vibrate([50]);
utils.showToast('📋 内容已复制到剪贴板');
}
});
document.getElementById('aiPlay').addEventListener('click', () => {
const textToSpeak = document.getElementById('aiText').textContent.trim();
if (textToSpeak) {
try {
const audio = new Audio(`${TTS_URL}${encodeURIComponent(textToSpeak)}&voiceName=${VOICE}`);
audio.play().catch(e => {
console.error('TTS Audio Playback Failed:', e);
utils.showToast('🔊 朗读功能暂时不可用 (播放失败)');
});
utils.vibrate([30]);
} catch (e) {
console.error('TTS Audio Object Creation Failed:', e);
utils.showToast('🔊 朗读功能暂时不可用 (创建音频失败)');
}
} else {
utils.showToast('🔊 没有可朗读的文本');
console.warn("TTS: No text found in #aiText for speaking.");
}
});
uiElements.aiModal.addEventListener('click', (e) => {
if (e.target === uiElements.aiModal) {
aiModalController.close();
}
});
// Settings Modal Listeners
document.getElementById('saveSettingsBtn').addEventListener('click', settingsModalController.save.bind(settingsModalController));
document.getElementById('closeSettingsBtn').addEventListener('click', settingsModalController.close.bind(settingsModalController));
uiElements.settingsModal.addEventListener('click', (e) => {
if (e.target === uiElements.settingsModal) {
settingsModalController.close();
}
});
// 监听单选框变化,切换设置面板
document.querySelectorAll('input[name="activeService"]').forEach(radio => {
radio.addEventListener('change', (e) => {
userSettings.activeService = e.target.value;
settingsModalController.updateView();
});
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (appState.isAiModalOpen && e.key === 'Escape') {
aiModalController.close();
} else if (appState.isSettingsModalOpen && e.key === 'Escape') {
settingsModalController.close();
}
});
// Floating AI Button Events
uiElements.floatingAiButton.addEventListener('click', triggerAIExplanation);
// --- 浮动 AI 按钮可见性逻辑 ---
let lastWindowScrollYForFloatingButton = window.scrollY;
let scrollTimeoutFloatingButton;
const showFloatingAiButton = () => {
if (!appState.isAiModalOpen && !appState.isSettingsModalOpen) {
uiElements.floatingAiButton.style.display = 'flex';
uiElements.floatingAiButton.style.opacity = '1';
uiElements.floatingAiButton.style.transform = 'translateY(-70%) scale(1)';
}
};
const hideFloatingAiButton = () => {
uiElements.floatingAiButton.style.opacity = '0';
uiElements.floatingAiButton.style.transform = 'translateY(-70%) scale(0.8)';
clearTimeout(scrollTimeoutFloatingButton);
scrollTimeoutFloatingButton = setTimeout(() => {
uiElements.floatingAiButton.style.display = 'none';
}, 300);
};
const updateFloatingAiButtonVisibility = utils.debounce(() => {
if (appState.isAiModalOpen || appState.isSettingsModalOpen) {
hideFloatingAiButton();
return;
}
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
showFloatingAiButton();
} else {
if (window.scrollY > 100) hideFloatingAiButton();
}
}, DEBOUNCE_DELAY);
window.addEventListener('scroll', utils.throttle(() => {
if (appState.isAiModalOpen || appState.isSettingsModalOpen) {
return;
}
const currentWindowScrollY = window.scrollY;
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
showFloatingAiButton();
} else {
if (currentWindowScrollY > lastWindowScrollYForFloatingButton && currentWindowScrollY > 100) {
hideFloatingAiButton();
} else if (currentWindowScrollY < lastWindowScrollYForFloatingButton || currentWindowScrollY <= 100) {
showFloatingAiButton();
}
}
lastWindowScrollYForFloatingButton = currentWindowScrollY;
}, 100));
document.addEventListener('selectionchange', updateFloatingAiButtonVisibility);
updateFloatingAiButtonVisibility();
}
// Function to register settings menu commands
function registerSettingsMenu() {
GM_registerMenuCommand('⚙️ AI设置 (GROQ/KIMI/ZHIPU)', () => {
settingsModalController.open();
});
}
// --- 初始化 ---
function init() {
createCommonUI(); // 创建 AI 和 Settings 模态框
setupNetworkMonitoring(); // 开始监控网络状态
setupGlobalEventListeners(); // 绑定所有事件监听器
CacheManager.cleanup(); // 清理过期缓存
// 注册(不可用) Tampermonkey 菜单命令
registerSettingsMenu();
console.log(`GROQ/KIMI/ZHIPU AI解释脚本(精简版 - 自动切换)已加载 - 默认服务: ${userSettings.activeService}`);
}
// 确保 DOM 准备就绪
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();