Text Summarizer with Gemini API

Summarize selected text using Gemini 2.0 Flash API

目前为 2025-03-09 提交的版本。查看 最新版本

// ==UserScript==
// @name         Text Summarizer with Gemini API
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Summarize selected text using Gemini 2.0 Flash API
// @author       Hà Trọng Nguyễn
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @connect      generativelanguage.googleapis.com
// @homepageURL  https://github.com/htrnguyen
// @supportURL   https://github.com/htrnguyen/Text-Summarizer-with-Gemini-API/issues
// @icon         https://github.com/htrnguyen/User-Scripts/raw/main/Text-Summarizer-with-Gemini-API/text-summarizer-logo.png
// @license      MIT
// ==/UserScript==

;(function () {
    'use strict'

    // Khai báo biến toàn cục
    let API_KEY = GM_getValue('geminiApiKey', '') || ''
    let shortcutKey = GM_getValue('shortcutKey', 't')
    let modifierKeys = JSON.parse(GM_getValue('modifierKeys', '["Alt"]')) || [
        'Alt',
    ]
    let currentPopup = null
    let isDragging = false
    let isResizing = false
    let offsetX, offsetY, resizeOffsetX, initialWidth

    // Hàm khởi tạo
    function initialize() {
        if (!API_KEY) {
            showPopup('Cài đặt', getSettingsContent())
        }
        setupEventListeners()
    }

    // Thiết lập các sự kiện
    function setupEventListeners() {
        document.addEventListener('keydown', handleKeydown)
        if (typeof GM_registerMenuCommand !== 'undefined') {
            GM_registerMenuCommand('Cài đặt Text Summarizer', () =>
                showPopup('Cài đặt', getSettingsContent())
            )
        }
    }

    // Kiểm tra phím tắt
    function checkShortcut(e) {
        const key = e.key.toLowerCase()
        const modifiers = modifierKeys.map((mod) => mod.toLowerCase())
        const currentModifiers = []
        if (e.altKey) currentModifiers.push('alt')
        if (e.ctrlKey) currentModifiers.push('ctrl')
        if (e.shiftKey) currentModifiers.push('shift')
        return (
            key === shortcutKey &&
            currentModifiers.sort().join(',') === modifiers.sort().join(',')
        )
    }

    // Xử lý phím tắt và ESC
    function handleKeydown(e) {
        if (checkShortcut(e)) {
            e.preventDefault()
            const selectedText = window.getSelection().toString().trim()
            if (selectedText) {
                summarizeText(selectedText)
            } else {
                showPopup(
                    'Lỗi',
                    'Vui lòng chọn một đoạn văn bản để tóm tắt nhé!'
                )
            }
        } else if (e.key === 'Escape' && currentPopup) {
            closePopup()
        }
    }

    // Gửi yêu cầu đến Gemini API
    function summarizeText(text) {
        showLoader()
        GM_xmlhttpRequest({
            method: 'POST',
            url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${API_KEY}`,
            headers: {'Content-Type': 'application/json'},
            data: JSON.stringify({
                contents: [
                    {
                        parts: [
                            {
                                text: `Tóm tắt nội dung sau đây, đảm bảo giữ lại các ý chính và chi tiết quan trọng, tránh lược bỏ quá nhiều. Kết quả cần có xuống dòng và bố cục hợp lý để dễ đọc. Chỉ bao gồm thông tin cần tóm tắt, không thêm phần thừa như 'dưới đây là tóm tắt' hoặc lời dẫn. Định dạng trả về là văn bản thông thường, không sử dụng markdown. Bạn có thể thêm emoji (🌟, ➡️, 1️⃣) để làm dấu chấm, số thứ tự hoặc gạch đầu dòng, nhưng hãy hạn chế và sử dụng một cách tinh tế. Nội dung cần tóm tắt là: ${text}`,
                            },
                        ],
                    },
                ],
            }),
            onload: function (response) {
                hideLoader()
                const data = JSON.parse(response.responseText)
                if (data.candidates && data.candidates.length > 0) {
                    const summary =
                        data.candidates[0].content.parts[0].text ||
                        'Không thể tóm tắt được nội dung này!'
                    showPopup('Tóm tắt', summary)
                } else if (data.error) {
                    showPopup('Lỗi', `Có lỗi từ API: ${data.error.message}`)
                } else {
                    showPopup(
                        'Lỗi',
                        'Phản hồi từ API không hợp lệ. Hãy thử lại!'
                    )
                }
            },
            onerror: function (error) {
                hideLoader()
                showPopup(
                    'Lỗi',
                    `Lỗi kết nối: ${error.message}. Kiểm tra mạng nhé!`
                )
            },
        })
    }

    // Hiển thị popup duy nhất
    function showPopup(title, content) {
        closePopup() // Đóng popup cũ trước khi mở mới

        currentPopup = document.createElement('div')
        currentPopup.className = 'summarizer-popup'
        currentPopup.innerHTML = `
            <div class="popup-header">
                <h2>${title}</h2>
                <div class="header-actions">
                    <button class="close-btn">×</button>
                </div>
            </div>
            <div class="${
                title === 'Tóm tắt' ? 'popup-content-summary' : 'popup-content'
            }">${content}</div>
            <div class="resize-handle"></div>
        `
        document.body.appendChild(currentPopup)

        currentPopup.style.opacity = '0'
        currentPopup.style.transform = 'translate(-50%, -50%) scale(0.9)'
        setTimeout(() => {
            currentPopup.style.opacity = '1'
            currentPopup.style.transform = 'translate(-50%, -50%) scale(1)'
        }, 10)

        currentPopup
            .querySelector('.close-btn')
            .addEventListener('click', closePopup)

        if (title === 'Cài đặt') {
            const saveBtn = currentPopup.querySelector('.save-btn')
            if (saveBtn) saveBtn.addEventListener('click', saveSettings)
        }

        const header = currentPopup.querySelector('.popup-header')
        header.addEventListener('mousedown', startDrag)
        document.addEventListener('mousemove', drag)
        document.addEventListener('mouseup', stopDrag)

        const resizeHandle = currentPopup.querySelector('.resize-handle')
        resizeHandle.addEventListener('mousedown', startResize)
        document.addEventListener('mousemove', resize)
        document.addEventListener('mouseup', stopResize)

        document.body.style.pointerEvents = 'none'
        currentPopup.style.pointerEvents = 'auto'
    }

    // Lấy nội dung cài đặt
    function getSettingsContent() {
        return `
            <div class="settings-container">
                <div class="settings-item">
                    <label>API Key:</label>
                    <input type="text" id="apiKeyInput" placeholder="Dán API key vào đây" value="${API_KEY}" />
                </div>
                <div class="settings-item instruction">
                    <span>Lấy key tại: <a href="https://aistudio.google.com/apikey" target="_blank">Google AI Studio</a></span>
                </div>
                <div class="settings-item shortcut-section">
                    <label>Phím tắt:</label>
                    <div class="shortcut-controls">
                        <label><input type="radio" name="modifier" value="Alt" ${
                            modifierKeys.includes('Alt') ? 'checked' : ''
                        }> Alt</label>
                        <label><input type="radio" name="modifier" value="Ctrl" ${
                            modifierKeys.includes('Ctrl') ? 'checked' : ''
                        }> Ctrl</label>
                        <label><input type="radio" name="modifier" value="Shift" ${
                            modifierKeys.includes('Shift') ? 'checked' : ''
                        }> Shift</label>
                        <input type="text" id="shortcutKey" maxlength="1" placeholder="T" value="${shortcutKey.toUpperCase()}" />
                    </div>
                </div>
                <div class="settings-item button-container">
                    <button class="save-btn">
                        <svg class="save-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
                            <path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
                        </svg>
                        Lưu
                    </button>
                </div>
            </div>
        `
    }

    // Lưu cài đặt và làm mới trang
    function saveSettings() {
        const apiKey = document.getElementById('apiKeyInput').value.trim()
        const selectedModifier = document.querySelector(
            'input[name="modifier"]:checked'
        )
        shortcutKey = document
            .getElementById('shortcutKey')
            .value.trim()
            .toLowerCase()

        if (!apiKey) {
            showPopup('Lỗi', 'Bạn chưa nhập API key! Hãy nhập để tiếp tục.')
            return
        }
        if (!shortcutKey) {
            showPopup('Lỗi', 'Bạn chưa nhập phím tắt! Hãy nhập một ký tự.')
            return
        }
        if (!selectedModifier) {
            showPopup(
                'Lỗi',
                'Bạn chưa chọn phím bổ trợ! Chọn Alt, Ctrl hoặc Shift.'
            )
            return
        }

        modifierKeys = [selectedModifier.value]
        GM_setValue('geminiApiKey', apiKey)
        GM_setValue('shortcutKey', shortcutKey)
        GM_setValue('modifierKeys', JSON.stringify(modifierKeys))
        API_KEY = apiKey
        closePopup()
        showPopup(
            'Thành công',
            'Cài đặt đã được lưu! Trang sẽ làm mới sau 1 giây.'
        )
        setTimeout(() => location.reload(), 1000) // Làm mới trang để tránh bị kẹt
    }

    // Xử lý kéo popup
    function startDrag(e) {
        isDragging = true
        offsetX = e.clientX - currentPopup.offsetLeft
        offsetY = e.clientY - currentPopup.offsetTop
    }

    function drag(e) {
        if (isDragging) {
            e.preventDefault()
            currentPopup.style.left = `${e.clientX - offsetX}px`
            currentPopup.style.top = `${e.clientY - offsetY}px`
        }
    }

    function stopDrag() {
        isDragging = false
    }

    // Xử lý thay đổi kích thước (chỉ chiều ngang)
    function startResize(e) {
        isResizing = true
        initialWidth = currentPopup.offsetWidth
        resizeOffsetX = e.clientX - currentPopup.offsetLeft
    }

    function resize(e) {
        if (isResizing) {
            const newWidth =
                initialWidth +
                (e.clientX - (currentPopup.offsetLeft + resizeOffsetX))
            currentPopup.style.width = `${Math.max(newWidth, 400)}px`
        }
    }

    function stopResize() {
        isResizing = false
    }

    // Loader
    function showLoader() {
        const loader = document.createElement('div')
        loader.className = 'summarizer-loader'
        loader.innerHTML = '<div class="spinner"></div>'
        document.body.appendChild(loader)
    }

    function hideLoader() {
        const loader = document.querySelector('.summarizer-loader')
        if (loader) loader.remove()
    }

    // Đóng popup
    function closePopup() {
        if (currentPopup) {
            currentPopup.style.opacity = '1'
            currentPopup.style.transform = 'translate(-50%, -50%) scale(1)'
            setTimeout(() => {
                currentPopup.style.opacity = '0'
                currentPopup.style.transform =
                    'translate(-50%, -50%) scale(0.9)'
                setTimeout(() => {
                    currentPopup.remove()
                    currentPopup = null
                    document.body.style.pointerEvents = 'auto'
                    document.removeEventListener('mousemove', drag)
                    document.removeEventListener('mouseup', stopDrag)
                }, 200)
            }, 10)
        }
    }

    // CSS
    const style = document.createElement('style')
    style.innerHTML = `
        .summarizer-popup {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 500px;
            min-width: 400px;
            height: 400px;
            background: #ffffff;
            border-radius: 12px;
            box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15);
            z-index: 9999;
            font-family: 'Roboto', sans-serif;
            overflow: hidden;
            transition: opacity 0.2s ease, transform 0.2s ease;
            display: flex;
            flex-direction: column;
        }
        .popup-header {
            background: linear-gradient(135deg, #4A90E2, #357ABD);
            color: #fff;
            padding: 15px 20px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            cursor: move;
            border-top-left-radius: 12px;
            border-top-right-radius: 12px;
            flex-shrink: 0;
        }
        .popup-header h2 {
            margin: 0;
            font-size: 20px;
            font-weight: 600;
            text-align: center;
            line-height: 1.0;
        }
        .header-actions {
            display: flex;
            gap: 12px;
        }
        .header-actions button {
            background: none;
            border: none;
            color: #fff;
            font-size: 22px;
            cursor: pointer;
            transition: transform 0.2s ease, opacity 0.2s ease;
        }
        .header-actions button:hover {
            transform: scale(1.1);
            opacity: 0.9;
        }
        .popup-content {
            padding: 15px;
            font-size: 15px;
            color: #444;
            line-height: 1.2;
            flex-grow: 1;
            display: flex;
            flex-direction: column;
            justify-content: space-between;
        }
        .popup-content-summary {
            padding: 15px;
            font-size: 15px;
            color: #444;
            line-height: 1.6;
            overflow-y: auto;
            white-space: pre-wrap;
            flex-grow: 1;
        }
        .settings-container {
            display: flex;
            flex-direction: column;
            gap: 10px;
        }
        .settings-item {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 5px;
        }
        .settings-item label {
            font-weight: 600;
            color: #333;
            line-height: 1.2;
        }
        .settings-item input[type="text"] {
            width: 80%;
            max-width: 300px;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 6px;
            font-size: 14px;
            text-align: center;
            background: #f9f9f9;
            transition: border-color 0.2s ease;
        }
        .settings-item input[type="text"]:focus {
            border-color: #4A90E2;
            outline: none;
        }
        .instruction {
            font-size: 13px;
            color: #666;
            line-height: 1.2;
        }
        .instruction a {
            color: #4A90E2;
            text-decoration: none;
            transition: color 0.2s ease;
        }
        .instruction a:hover {
            color: #357ABD;
            text-decoration: underline;
        }
        .shortcut-section {
            flex-direction: row;
            justify-content: center;
            align-items: center;
            gap: 10px;
        }
        .shortcut-controls {
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .shortcut-controls label {
            display: flex;
            align-items: center;
            gap: 3px;
            font-size: 14px;
            font-weight: 400;
            color: #444;
        }
        .shortcut-controls input[type="radio"] {
            margin: 0;
        }
        .shortcut-controls input[type="text"] {
            width: 40px;
            padding: 6px;
            border: 1px solid #ddd;
            border-radius: 6px;
            font-size: 14px;
            text-align: center;
            background: #f9f9f9;
            transition: border-color 0.2s ease;
        }
        .shortcut-controls input[type="text"]:focus {
            border-color: #4A90E2;
            outline: none;
        }
        .button-container {
            margin-top: 10px;
        }
        .save-btn {
            padding: 8px 20px;
            background: linear-gradient(135deg, #4A90E2, #357ABD);
            color: #fff;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 15px;
            font-weight: 500;
            display: flex;
            align-items: center;
            gap: 6px;
            transition: transform 0.2s ease, box-shadow 0.2s ease;
        }
        .save-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(74, 144, 226, 0.4);
        }
        .save-icon {
            width: 16px;
            height: 16px;
        }
        .resize-handle {
            position: absolute;
            bottom: 0;
            right: 0;
            width: 20px;
            height: 20px;
            background: #4A90E2;
            cursor: se-resize;
            border-bottom-right-radius: 12px;
            transition: background 0.2s ease;
        }
        .resize-handle:hover {
            background: #357ABD;
        }
        .summarizer-loader {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.6);
            display: flex;
            justify-content: center;
            align-items: center;
            z-index: 10000;
        }
        .spinner {
            border: 5px solid rgba(255, 255, 255, 0.3);
            border-top: 5px solid #4A90E2;
            border-radius: 50%;
            width: 50px;
            height: 50px;
            animation: spin 1s linear infinite;
        }
        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
    `
    document.head.appendChild(style)

    // Thêm font Roboto
    const fontLink = document.createElement('link')
    fontLink.href =
        'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600&display=swap'
    fontLink.rel = 'stylesheet'
    document.head.appendChild(fontLink)

    // Khởi chạy script
    initialize()
})()