移动端右滑英文段落,AI自动分析并添加中文短语注解,支持针对段落内容的持续追问。
// ==UserScript==
// @name SwipeSense Plus
// @namespace http://tampermonkey.net/
// @version 0.2
// @description 移动端右滑英文段落,AI自动分析并添加中文短语注解,支持针对段落内容的持续追问。
// @author MoodHappy
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_notification
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// 默认配置
const DEFAULT_CONFIG = {
url: "https://api.chatanywhere.tech/v1/chat/completions",
key: "",
model: "gpt-5-nano-ca"
};
// 缓存键名
const CACHE_KEY = 'ai_annotation_cache';
// ================= 样式注入 =================
GM_addStyle(`
/* 设置面板蒙层 */
#ai-config-modal {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
z-index: 100000;
display: flex;
justify-content: center;
align-items: center;
backdrop-filter: blur(3px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
#ai-config-modal.show { opacity: 1; pointer-events: auto; }
/* 设置面板主体 */
.ai-config-card {
background: white;
width: 85%;
max-width: 400px;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
font-family: sans-serif;
position: relative;
}
.ai-config-card h3 { margin: 0 0 15px 0; color: #333; font-size: 18px; text-align: center; }
.ai-form-group { margin-bottom: 12px; }
.ai-form-group label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
.ai-form-group input {
width: 100%;
padding: 8px;
box-sizing: border-box;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background: #f9f9f9;
}
.ai-btn-row { display: flex; gap: 10px; margin-top: 20px; }
.ai-btn {
flex: 1;
padding: 10px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.ai-btn-save { background: #0ea5e9; color: white; }
.ai-btn-cancel { background: #e2e8f0; color: #333; }
.ai-btn-clear { background: #ef4444; color: white; font-size: 12px; padding: 4px 8px; position: absolute; top: 20px; right: 20px; border-radius: 4px; cursor: pointer; border: none;}
/* 注解框样式 */
.ai-note-box {
background-color: #f0f9ff;
border-left: 4px solid #0ea5e9;
margin: 8px 0 16px 0;
padding: 0; /* Padding moved to inner containers */
border-radius: 6px;
font-size: 14px;
line-height: 1.6;
color: #334155;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
animation: fadeIn 0.3s ease-in-out;
font-family: sans-serif;
overflow: hidden;
}
.ai-note-main { padding: 12px; }
.ai-note-loading { padding: 12px; color: #64748b; font-style: italic; font-size: 13px; display: flex; align-items: center; gap: 6px;}
.ai-note-title { font-weight: bold; color: #0369a1; margin-bottom: 6px; font-size: 12px; text-transform: uppercase; display: flex; justify-content: space-between;}
.ai-note-source { font-weight: normal; font-size: 10px; color: #94a3b8; }
.ai-note-content ul { margin: 0; padding-left: 18px; }
.ai-note-content li { margin-bottom: 5px; }
/* 追问区域样式 */
.ai-chat-section {
background: #e0f2fe;
border-top: 1px solid #bae6fd;
padding: 10px;
}
.ai-chat-history {
margin-bottom: 10px;
font-size: 13px;
}
.ai-msg-user {
text-align: right;
margin-bottom: 6px;
color: #555;
font-size: 12px;
}
.ai-msg-user span {
background: #fff;
padding: 4px 8px;
border-radius: 8px 8px 0 8px;
display: inline-block;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.ai-msg-ai {
text-align: left;
margin-bottom: 8px;
color: #333;
}
.ai-msg-ai span {
display: block;
background: rgba(255,255,255,0.6);
padding: 6px 8px;
border-radius: 0 8px 8px 8px;
border-left: 2px solid #0ea5e9;
}
.ai-input-wrapper {
display: flex;
gap: 6px;
}
.ai-chat-input {
flex: 1;
padding: 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.ai-chat-input:focus { border-color: #0ea5e9; }
.ai-chat-btn {
background: #0ea5e9;
color: white;
border: none;
border-radius: 4px;
padding: 0 12px;
font-size: 13px;
cursor: pointer;
}
.ai-chat-btn:disabled { background: #94a3b8; cursor: not-allowed; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
`);
// ================= 工具函数:简单哈希 =================
function generateHash(str) {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return "h" + Math.abs(hash);
}
// ================= UI 构建逻辑 =================
function createUI() {
const modal = document.createElement('div');
modal.id = 'ai-config-modal';
modal.innerHTML = `
<div class="ai-config-card">
<h3>AI 配置</h3>
<button id="ai-btn-clear-cache" class="ai-btn-clear" title="清除所有已保存的注解缓存">清除缓存</button>
<div class="ai-form-group">
<label>API URL (Base URL)</label>
<input type="text" id="ai-input-url" placeholder="https://api.openai.com/v1/chat/completions">
</div>
<div class="ai-form-group">
<label>API Key</label>
<input type="password" id="ai-input-key" placeholder="sk-...">
</div>
<div class="ai-form-group">
<label>模型名称 (Model)</label>
<input type="text" id="ai-input-model" placeholder="gpt-4o-mini">
</div>
<div class="ai-btn-row">
<button class="ai-btn ai-btn-cancel" id="ai-btn-close">取消</button>
<button class="ai-btn ai-btn-save" id="ai-btn-save">保存</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('ai-btn-close').onclick = closeSettings;
document.getElementById('ai-btn-save').onclick = saveSettings;
document.getElementById('ai-btn-clear-cache').onclick = clearCache;
modal.onclick = (e) => { if(e.target === modal) closeSettings(); };
}
function openSettings() {
document.getElementById('ai-input-url').value = GM_getValue('url', DEFAULT_CONFIG.url);
document.getElementById('ai-input-key').value = GM_getValue('key', DEFAULT_CONFIG.key);
document.getElementById('ai-input-model').value = GM_getValue('model', DEFAULT_CONFIG.model);
document.getElementById('ai-config-modal').classList.add('show');
}
function closeSettings() {
document.getElementById('ai-config-modal').classList.remove('show');
}
function saveSettings() {
const url = document.getElementById('ai-input-url').value.trim();
const key = document.getElementById('ai-input-key').value.trim();
const model = document.getElementById('ai-input-model').value.trim();
if(!url || !key || !model) {
alert("请完整填写所有信息!");
return;
}
GM_setValue('url', url);
GM_setValue('key', key);
GM_setValue('model', model);
closeSettings();
GM_notification({ text: "设置已保存", timeout: 1500 });
}
function clearCache() {
if(confirm('确定要清除所有已保存的段落注解缓存吗?')) {
GM_setValue(CACHE_KEY, {});
GM_notification({ text: "缓存已清空", timeout: 1500 });
}
}
createUI();
GM_registerMenuCommand("⚙️ AI 配置设置", openSettings);
// ================= 滑动交互逻辑 =================
let touchStartX = 0;
let touchStartY = 0;
const SWIPE_THRESHOLD = 80;
const Y_LIMIT = 60;
document.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
}, { passive: true });
document.addEventListener('touchend', (e) => {
const touchEndX = e.changedTouches[0].screenX;
const touchEndY = e.changedTouches[0].screenY;
handleSwipe(e.target, touchStartX, touchEndX, touchStartY, touchEndY);
}, { passive: true });
function handleSwipe(target, startX, endX, startY, endY) {
const diffX = endX - startX;
const diffY = Math.abs(endY - startY);
if (diffX > SWIPE_THRESHOLD && diffY < Y_LIMIT) {
const paragraph = target.closest('p');
if (paragraph && paragraph.textContent.trim().length > 20) {
if (!GM_getValue('key')) {
openSettings();
return;
}
toggleAnnotation(paragraph);
}
}
}
// ================= 注解逻辑 (含缓存) =================
function toggleAnnotation(pElement) {
const existingNote = pElement.nextElementSibling;
if (existingNote && existingNote.classList.contains('ai-note-box')) {
existingNote.remove();
return;
}
const noteBox = document.createElement('div');
noteBox.className = 'ai-note-box';
const text = pElement.textContent.trim();
const hash = generateHash(text);
const cache = GM_getValue(CACHE_KEY, {});
pElement.parentNode.insertBefore(noteBox, pElement.nextSibling);
if (cache[hash]) {
renderContent(noteBox, cache[hash], true, text);
} else {
noteBox.innerHTML = `<div class="ai-note-loading">⚡ AI 正在分析...</div>`;
fetchAIExplanation(text, hash, noteBox);
}
}
// 渲染内容的辅助函数,增加了追问输入框
function renderContent(container, htmlContent, isCached, originalText) {
const sourceBadge = isCached ? '<span class="ai-note-source">From Cache</span>' : '<span class="ai-note-source">From API</span>';
// 构造HTML结构
container.innerHTML = `
<div class="ai-note-main">
<div class="ai-note-title">
<span>📝 AI 笔记</span>
${sourceBadge}
</div>
<div class="ai-note-content">${htmlContent}</div>
</div>
<div class="ai-chat-section">
<div class="ai-chat-history"></div>
<div class="ai-input-wrapper">
<input type="text" class="ai-chat-input" placeholder="针对此段追问 (如: 分析长难句)">
<button class="ai-chat-btn">发送</button>
</div>
</div>
`;
// 绑定追问事件
const input = container.querySelector('.ai-chat-input');
const btn = container.querySelector('.ai-chat-btn');
const historyDiv = container.querySelector('.ai-chat-history');
const handleSend = () => {
const question = input.value.trim();
if(!question) return;
// 1. 显示用户提问
const userMsg = document.createElement('div');
userMsg.className = 'ai-msg-user';
userMsg.innerHTML = `<span>${question}</span>`;
historyDiv.appendChild(userMsg);
input.value = '';
input.disabled = true;
btn.disabled = true;
btn.textContent = '...';
// 2. 发起追问请求
fetchFollowUp(originalText, question, (responseHTML) => {
// 3. 显示AI回答
const aiMsg = document.createElement('div');
aiMsg.className = 'ai-msg-ai';
aiMsg.innerHTML = `<span>${responseHTML}</span>`;
historyDiv.appendChild(aiMsg);
// 恢复输入框
input.disabled = false;
btn.disabled = false;
btn.textContent = '发送';
input.focus();
});
};
btn.onclick = handleSend;
input.onkeypress = (e) => { if(e.key === 'Enter') handleSend(); };
}
// 初始段落分析 API 调用
function fetchAIExplanation(text, hash, container) {
const config = getConfig();
const prompt = `
Analyze this English text. Identify 3-5 difficult idioms/phrasal verbs/words.
Provide Chinese meaning + brief context.
Output ONLY HTML <ul><li><b>Word</b>: Meaning</li></ul>.
No markdown blocks, no greeting.
Text: "${text}"
`;
callAI(config, [{ role: "user", content: prompt }], (content) => {
renderContent(container, content, false, text);
// 写入缓存
const cache = GM_getValue(CACHE_KEY, {});
cache[hash] = content;
GM_setValue(CACHE_KEY, cache);
}, (err) => {
container.innerHTML = `<div class="ai-note-main" style="color:red">错误: ${err}</div>`;
});
}
// 追问 API 调用
function fetchFollowUp(originalText, question, callback) {
const config = getConfig();
const prompt = `
Context text: "${originalText}"
User's question about the text: "${question}"
Please answer briefly and clearly in Chinese.
Use simple HTML tags (like <br>, <b>) for formatting if needed.
`;
callAI(config, [{ role: "user", content: prompt }], (content) => {
callback(content);
}, (err) => {
callback(`<em style="color:red">Error: ${err}</em>`);
});
}
// 通用 AI 请求函数
function callAI(config, messages, onSuccess, onError) {
GM_xmlhttpRequest({
method: "POST",
url: config.url,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${config.key}`
},
data: JSON.stringify({
model: config.model,
messages: [
{ role: "system", content: "You are a helpful language tutor." },
...messages
],
temperature: 0.3
}),
onload: function(response) {
if (response.status === 200) {
try {
const resJson = JSON.parse(response.responseText);
let content = resJson.choices[0].message.content;
content = content.replace(/```html|```/g, '').trim();
onSuccess(content);
} catch (err) {
onError("解析失败");
}
} else {
onError(`API Error (${response.status})`);
}
},
onerror: function(err) {
onError("网络错误");
}
});
}
function getConfig() {
return {
url: GM_getValue('url', DEFAULT_CONFIG.url),
key: GM_getValue('key', ''),
model: GM_getValue('model', DEFAULT_CONFIG.model)
};
}
})();