SwipeSense Plus

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();