SwipeSense Plus

移动端右滑英文段落,AI自动分析并添加中文短语注解,支持针对段落内容的持续追问。

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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)
        };
    }

})();