// ==UserScript== // @name 划词 搜索 & AI // @namespace http://tampermonkey.net/ // @version 4.6 // @description 划词后显示图标,展开窗口可切换搜索与AI连续对话。AI功能支持自定义服务端点和一键切换。 // @author lxzyz // @license CC-BY-NC-4.0 // @match http://*/* // @match https://*/* // @connect * // @connect api.deepseek.com // @connect www.baidu.com // @connect www.google.com // @connect www.bing.com // @require https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @downloadURL https://update.greasyfork.icu/scripts/552538/%E5%88%92%E8%AF%8D%20%E6%90%9C%E7%B4%A2%20%20AI.user.js // @updateURL https://update.greasyfork.icu/scripts/552538/%E5%88%92%E8%AF%8D%20%E6%90%9C%E7%B4%A2%20%20AI.meta.js // ==/UserScript== (function() { 'use strict'; // --- 配置 --- const searchEngines = { 'Baidu': { name: '百度', favicon: 'https://www.baidu.com/favicon.ico', searchUrl: 'https://www.baidu.com/s?wd=', baseUrl: 'https://www.baidu.com', method: 'background' }, 'Google': { name: '谷歌', favicon: 'https://www.google.com/favicon.ico', searchUrl: 'https://www.google.com/search?igu=1&q=', baseUrl: 'https://www.google.com', method: 'iframe' }, 'Bing': { name: '必应', favicon: 'https://www.bing.com/favicon.ico', searchUrl: 'https://cn.bing.com/search?q=', baseUrl: 'https://cn.bing.com', method: 'background' }, }; // --- AI 配置管理 --- const DEFAULT_AI_ID = 'deepseek_official'; const defaultAiConfigs = { [DEFAULT_AI_ID]: { name: 'DeepSeek', apiUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', apiKeyKey: 'deepseek_api_key' // The key for GM_setValue/GM_getValue } }; function getCustomAiConfigs() { return JSON.parse(GM_getValue('customAiConfigs', '{}')); } function saveCustomAiConfigs(configs) { GM_setValue('customAiConfigs', JSON.stringify(configs)); } function getAllAiConfigs() { const customConfigs = getCustomAiConfigs(); return { ...defaultAiConfigs, ...customConfigs }; } function getActiveAiConfigId() { return GM_getValue('activeAiConfigId', DEFAULT_AI_ID); } function getActiveAiConfig() { const allConfigs = getAllAiConfigs(); const activeId = getActiveAiConfigId(); // Fallback to default if active one was deleted or does not exist return allConfigs[activeId] || allConfigs[DEFAULT_AI_ID]; } // --- 菜单与API Key设置 --- let defaultEngine = GM_getValue('defaultSearchEngine', 'Baidu'); Object.keys(searchEngines).forEach(key => { GM_registerMenuCommand(`${defaultEngine === key ? '✅' : ' '} 设为默认引擎: ${searchEngines[key].name}`, () => { GM_setValue('defaultSearchEngine', key); alert(`默认搜索引擎已设置为: ${searchEngines[key].name}。刷新页面后生效。`); }); }); // --- AI 菜单 --- GM_registerMenuCommand('--- AI 配置 ---', () => {}); // 分割线 const allAiConfigs = getAllAiConfigs(); const activeAiId = getActiveAiConfigId(); Object.entries(allAiConfigs).forEach(([id, config]) => { GM_registerMenuCommand(`${id === activeAiId ? '✅' : ' '} 切换 AI 为: ${config.name}`, () => { GM_setValue('activeAiConfigId', id); alert(`AI 已切换为: ${config.name}。`); }); }); GM_registerMenuCommand('设置 AI 的 API Key', () => { const configs = getAllAiConfigs(); const configEntries = Object.entries(configs); const configChoices = configEntries.map(([id, config], index) => `${index + 1}. ${config.name}`).join('\n'); const choice = prompt(`请选择要设置 API Key 的 AI 配置 (输入数字):\n${configChoices}`, '1'); if (choice === null) return; const choiceIndex = parseInt(choice, 10) - 1; if (isNaN(choiceIndex) || choiceIndex < 0 || choiceIndex >= configEntries.length) { alert('无效的选择。'); return; } const [configId, selectedConfig] = configEntries[choiceIndex]; const currentKey = GM_getValue(selectedConfig.apiKeyKey, ''); const newKey = prompt(`请输入 [${selectedConfig.name}] 的 API Key:`, currentKey); if (newKey !== null) { GM_setValue(selectedConfig.apiKeyKey, newKey.trim()); alert('API Key 已' + (newKey.trim() ? '保存。' : '清除。')); } }); GM_registerMenuCommand('添加自定义 AI 配置', () => { const name = prompt("输入配置名称 (例如 'My Local LLM'):"); if (!name) return; const apiUrl = prompt("输入 API URL (例如 'http://localhost:1234/v1/chat/completions'):"); if (!apiUrl) return; const model = prompt("输入模型名称 (例如 'llama3-8b-instruct'):"); if (!model) return; const id = `custom_${Date.now()}`; const apiKeyKey = `custom_apikey_${id}`; const newConfig = { name, apiUrl, model, apiKeyKey }; const customConfigs = getCustomAiConfigs(); customConfigs[id] = newConfig; saveCustomAiConfigs(customConfigs); alert(`配置 "${name}" 已添加。您现在可以刷新页面,在菜单中切换并为其设置 API Key。`); }); GM_registerMenuCommand('删除自定义 AI 配置', () => { const customConfigs = getCustomAiConfigs(); if (Object.keys(customConfigs).length === 0) { alert('没有可删除的自定义配置。'); return; } const configChoices = Object.entries(customConfigs).map(([id, config]) => `- ${config.name}`).join('\n'); const choice = prompt(`请输入要删除的配置的完整名称:\n${configChoices}`); if (!choice) return; const entryToDelete = Object.entries(customConfigs).find(([id, config]) => config.name.trim() === choice.trim()); if (entryToDelete) { const [idToDelete, configToDelete] = entryToDelete; delete customConfigs[idToDelete]; saveCustomAiConfigs(customConfigs); GM_setValue(configToDelete.apiKeyKey, ''); // Also remove the stored API key alert(`配置 "${configToDelete.name}" 已删除。`); } else { alert('未找到该名称的配置。'); } }); // --- 样式 --- GM_addStyle(` :root { --main-blue: #4285f4; --light-blue: #e8f0fe; --border-color: #ddd; --bg-hover: #f0f0f0; --text-color: #333; --text-light: #888; } #search-trigger-icon { position: absolute; z-index: 2147483646 !important; width: 32px; height: 32px; background-color: #fff; border: 1px solid var(--border-color); border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,0.15); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.2s ease, box-shadow 0.2s ease; animation: iconFadeIn 0.2s ease-out; } #search-trigger-icon:hover { transform: scale(1.15); box-shadow: 0 6px 16px rgba(0,0,0,0.2); } #search-trigger-icon img { width: 20px; height: 20px; pointer-events: none; } @keyframes iconFadeIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } } #search-popup-container { position: absolute; z-index: 2147483647 !important; background-color: #fdfdfd; border: 1px solid #ccc; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15); padding: 0; width: 850px; height: 650px; overflow: hidden; resize: both; display: flex; flex-direction: column; animation: fadeIn 0.2s ease-out; user-select: none; } #search-popup-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; border-bottom: 1px solid #eee; background-color: #f7f7f7; flex-shrink: 0; cursor: move; } #search-engine-switcher { display: flex; align-items: center; gap: 6px; } .engine-switch-btn, #ai-switch-btn { cursor: pointer; border: 2px solid transparent; border-radius: 50%; padding: 2px; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; } .engine-switch-btn:hover, #ai-switch-btn:hover { background-color: var(--bg-hover); } .engine-switch-btn.active, #ai-switch-btn.active { border-color: var(--main-blue); background-color: var(--light-blue); } .engine-switch-btn img { width: 16px; height: 16px; pointer-events: none; } #ai-switch-btn svg { width: 16px; height: 16px; stroke: #5f6368; } #ai-switch-btn.active svg { stroke: var(--main-blue); } .header-divider { border-left: 1px solid var(--border-color); height: 20px; margin: 0 4px; } #search-popup-controls { display: flex; align-items: center; gap: 4px; } .popup-control-btn { cursor: pointer; border: none; background: none; font-size: 22px; color: var(--text-light); width: 28px; height: 28px; line-height: 28px; text-align: center; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s, color 0.2s; } .popup-control-btn:hover { background-color: var(--bg-hover); color: var(--text-color); } #search-popup-pin-btn.pinned { background-color: var(--light-blue); } #search-popup-pin-btn.pinned svg { fill: var(--main-blue); stroke: var(--main-blue); } #search-popup-pin-btn svg { width: 16px; height: 16px; stroke: var(--text-light); stroke-width: 2; fill: none; } #search-popup-content { flex-grow: 1; height: 100%; position: relative; } #search-popup-iframe { width: 100%; height: 100%; border: none; } #ai-view-container { display: none; flex-direction: column; width: 100%; height: 100%; padding: 12px; box-sizing: border-box; background-color: #fff; } #ai-response-area { flex-grow: 1; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; padding: 10px; font-size: 14px; line-height: 1.6; color: var(--text-color); } #ai-response-area .ai-message { padding: 8px 12px; border-radius: 10px; margin-bottom: 10px; max-width: 90%; word-wrap: break-word; } #ai-response-area .ai-message.user { background-color: var(--light-blue); margin-left: auto; } #ai-response-area .ai-message.assistant { background-color: #f1f1f1; margin-right: auto; } .ai-message h1, .ai-message h2, .ai-message h3 { border-bottom: 1px solid #eee; padding-bottom: 0.3em; margin-top: 24px; margin-bottom: 16px; } .ai-message p { margin-top: 0; margin-bottom: 16px; } .ai-message ul, .ai-message ol { padding-left: 2em; } .ai-message code { font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; background-color: #e0e0e0; padding: .2em .4em; font-size: 85%; border-radius: 6px; } .ai-message pre { background-color: #2d2d2d; color: #f1f1f1; padding: 16px; border-radius: 8px; overflow-x: auto; } .ai-message pre code { background-color: transparent; padding: 0; } .typing-cursor { display: inline-block; width: 8px; height: 1em; background-color: var(--text-color); animation: blink 1s step-end infinite; } @keyframes blink { from, to { background-color: transparent } 50% { background-color: var(--text-color); } } #ai-input-area { display: flex; gap: 8px; margin-top: 10px; flex-shrink: 0; } #ai-input { flex-grow: 1; padding: 8px 12px; border: 1px solid #ccc; border-radius: 18px; outline: none; transition: border-color 0.2s; } #ai-input:focus { border-color: var(--main-blue); } #ai-submit-btn { padding: 8px 16px; border: none; background-color: var(--main-blue); color: white; border-radius: 18px; cursor: pointer; transition: background-color 0.2s; } #ai-submit-btn:disabled { background-color: #ccc; cursor: not-allowed; } `); // --- 全局变量 --- let popup = null, searchIcon = null, currentSearchTerm = '', triggerTimer = null; let isDragging = false, isPinned = false; let offsetX, offsetY; let aiAbortController = null; let aiConversationHistory = []; // 用于存储对话历史 // --- UI 创建与销毁 --- function closeEverything(immediate = true) { if (aiAbortController) { aiAbortController.abort(); aiAbortController = null; } aiConversationHistory = []; // 清空对话历史 isPinned = false; if (popup) { document.body.removeChild(popup); popup = null; } if (searchIcon) { document.body.removeChild(searchIcon); searchIcon = null; } } // --- 核心功能函数 --- async function handleAiSubmit(question, submitBtn, responseArea, inputElem) { const activeAiConfig = getActiveAiConfig(); const apiKey = GM_getValue(activeAiConfig.apiKeyKey, ''); if (!apiKey) { const errorMessage = `
`; responseArea.innerHTML += errorMessage; // Directly append HTML responseArea.scrollTop = responseArea.scrollHeight; return; } if (!question.trim()) return; if (aiAbortController) { aiAbortController.abort(); } aiAbortController = new AbortController(); // 1. 更新对话历史和UI (用户部分) aiConversationHistory.push({ role: "user", content: question }); const userMessageDiv = document.createElement('div'); userMessageDiv.className = 'ai-message user'; userMessageDiv.innerHTML = window.marked.parse(question); responseArea.appendChild(userMessageDiv); responseArea.scrollTop = responseArea.scrollHeight; inputElem.value = ''; // 清空输入框 submitBtn.disabled = true; inputElem.disabled = true; submitBtn.textContent = '停止'; submitBtn.onclick = () => aiAbortController.abort(); // 2. 创建AI消息的容器 const assistantMessageDiv = document.createElement('div'); assistantMessageDiv.className = 'ai-message assistant'; responseArea.appendChild(assistantMessageDiv); let fullResponse = ""; try { const response = await fetch(activeAiConfig.apiUrl, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, body: JSON.stringify({ model: activeAiConfig.model, messages: aiConversationHistory, // 发送完整历史 stream: true }), signal: aiAbortController.signal }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error ? JSON.stringify(errorData.error) : `HTTP Error ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const dataStr = line.substring(6); if (dataStr.trim() === "[DONE]") break; try { const data = JSON.parse(dataStr); if (data.choices && data.choices[0].delta) { fullResponse += data.choices[0].delta.content || ""; assistantMessageDiv.innerHTML = window.marked.parse(fullResponse + ''); responseArea.scrollTop = responseArea.scrollHeight; } } catch (e) { /* Ignore incomplete JSON */ } } } } } catch (error) { if (error.name !== 'AbortError') { fullResponse += `\n\n**请求出错**: ${error.message}`; } } finally { // 3. 更新对话历史 (AI部分) if (fullResponse) { aiConversationHistory.push({ role: "assistant", content: fullResponse }); } assistantMessageDiv.innerHTML = window.marked.parse(fullResponse); responseArea.scrollTop = responseArea.scrollHeight; submitBtn.disabled = false; inputElem.disabled = false; submitBtn.textContent = '发送'; submitBtn.onclick = () => handleAiSubmit(inputElem.value, submitBtn, responseArea, inputElem); aiAbortController = null; } } function createSearchIcon(x, y) { closeEverything(true); defaultEngine = GM_getValue('defaultSearchEngine', 'Baidu'); searchIcon = document.createElement('div'); searchIcon.id = 'search-trigger-icon'; searchIcon.innerHTML = `