豆包/元宝 快捷输入与对话导出悬浮球 - 1.2

// ==UserScript==
// @name         豆包/元宝 快捷输入与对话导出悬浮球
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  在豆包 / 腾讯元宝网页端添加悬浮球:一个用于快捷输入,另一个一键把当前对话导出为 Markdown 文件
// @author       Your Name
// @match        https://www.doubao.com/*
// @match        https://doubao.com/*
// @match        https://yuanbao.tencent.com/*
// @icon         https://www.doubao.com/favicon.ico
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 默认快捷语句
    const defaultQuickInputs = [
        '你好,有什么可以帮助我的吗?',
        '请详细解释这个概念',
        '举个例子说明一下',
        '还有其他方法吗?',
        '谢谢,我明白了'
    ];

    // ===================== 站点配置 =====================
    // 不同平台 DOM 结构不同,抽取逻辑全部从这里取,便于后续扩展新平台
    const SITES = {
        doubao: {
            label: '豆包',
            test: (host) => host.includes('doubao.com'),
            msgRow: ['div.v_list_row', '[data-testid="message-block-container"]', '[data-role="message"]'],
            isUser: (row) => {
                if (row.querySelector('[data-target-id*="message-box-target-id"]')) return true;
                if (row.querySelector('textarea, [contenteditable="true"]')) return true;
                return /(^|\s)user(\s|$)/i.test(row.className);
            },
            isAssistant: (row) => {
                if (row.querySelector('[data-copy-telemetry="right_click_copy"]')) return true;
                return /(^|\s)(assistant|bot|ai)(\s|$)/i.test(row.className);
            },
            contentEl: (row) => row.querySelector('div[data-testid="message_text_content"]') || row,
            loadMore: false
        },
        yuanbao: {
            label: '元宝',
            test: (host) => host.includes('yuanbao.tencent.com'),
            msgRow: ['.agent-chat__list__item'],
            isUser: (row) => row.classList.contains('agent-chat__list__item--human'),
            isAssistant: (row) => !row.classList.contains('agent-chat__list__item--human'),
            contentEl: (row) => {
                if (row.classList.contains('agent-chat__list__item--human')) {
                    return row.querySelector('.hyc-content-text') || row;
                }
                return row.querySelector('.hyc-content-md') || row;
            },
            loadMore: true
        }
    };

    // 根据当前域名确定使用哪套配置
    function getSiteConfig() {
        const host = location.hostname;
        for (const key in SITES) {
            if (SITES[key].test(host)) return SITES[key];
        }
        return SITES.doubao; // 默认按豆包处理
    }

    // ===================== 快捷输入相关 =====================

    // 从本地存储获取快捷语句,若不存在则使用默认值
    function getQuickInputs() {
        const saved = localStorage.getItem('doubaoQuickInputs');
        return saved ? JSON.parse(saved) : defaultQuickInputs;
    }

    // 保存快捷语句到本地存储
    function saveQuickInputs(inputs) {
        localStorage.setItem('doubaoQuickInputs', JSON.stringify(inputs));
    }

    // 创建快捷输入悬浮球元素
    function createFloatingBall() {
        const ball = document.createElement('div');
        ball.id = 'doubao-floating-ball';
        ball.style.position = 'fixed';
        ball.style.right = '20px';
        ball.style.top = '50%';
        ball.style.transform = 'translateY(-50%)';
        ball.style.width = '60px';
        ball.style.height = '60px';
        ball.style.borderRadius = '50%';
        ball.style.backgroundColor = '#1677ff';
        ball.style.color = 'white';
        ball.style.display = 'flex';
        ball.style.alignItems = 'center';
        ball.style.justifyContent = 'center';
        ball.style.fontSize = '24px';
        ball.style.cursor = 'pointer';
        ball.style.zIndex = '9999';
        ball.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
        ball.style.transition = 'all 0.3s ease';
        ball.innerHTML = '💬';

        // 鼠标悬停效果
        ball.addEventListener('mouseenter', function() {
            ball.style.transform = 'translateY(-50%) scale(1.1)';
        });

        ball.addEventListener('mouseleave', function() {
            ball.style.transform = 'translateY(-50%) scale(1)';
        });

        return ball;
    }

    // 创建快捷输入面板
    function createQuickInputPanel() {
        const panel = document.createElement('div');
        panel.id = 'doubao-quick-input-panel';
        panel.style.position = 'fixed';
        panel.style.right = '90px';
        panel.style.top = '50%';
        panel.style.transform = 'translateY(-50%)';
        panel.style.width = '300px';
        panel.style.backgroundColor = 'white';
        panel.style.borderRadius = '8px';
        panel.style.boxShadow = '0 4px 16px rgba(0,0,0,0.15)';
        panel.style.zIndex = '9998';
        panel.style.padding = '16px';
        panel.style.display = 'none';
        panel.style.maxHeight = '400px';
        panel.style.overflowY = 'auto';

        // 面板标题
        const title = document.createElement('h3');
        title.style.marginTop = '0';
        title.style.marginBottom = '16px';
        title.style.fontSize = '16px';
        title.style.color = '#333';
        title.textContent = '快捷输入';
        panel.appendChild(title);

        // 快捷语句列表
        const list = document.createElement('div');
        list.id = 'doubao-quick-input-list';
        panel.appendChild(list);

        // 添加新快捷语句的输入框
        const addSection = document.createElement('div');
        addSection.style.marginTop = '16px';
        addSection.style.paddingTop = '16px';
        addSection.style.borderTop = '1px solid #eee';

        const input = document.createElement('input');
        input.type = 'text';
        input.placeholder = '添加新的快捷语句';
        input.style.width = '100%';
        input.style.padding = '8px';
        input.style.border = '1px solid #ddd';
        input.style.borderRadius = '4px';
        input.style.marginBottom = '8px';
        addSection.appendChild(input);

        const addButton = document.createElement('button');
        addButton.textContent = '添加';
        addButton.style.padding = '6px 12px';
        addButton.style.backgroundColor = '#1677ff';
        addButton.style.color = 'white';
        addButton.style.border = 'none';
        addButton.style.borderRadius = '4px';
        addButton.style.cursor = 'pointer';
        addSection.appendChild(addButton);

        panel.appendChild(addSection);

        // 添加按钮点击事件
        addButton.addEventListener('click', function() {
            const newInput = input.value.trim();
            if (newInput) {
                const quickInputs = getQuickInputs();
                quickInputs.push(newInput);
                saveQuickInputs(quickInputs);
                renderQuickInputs();
                input.value = '';
            }
        });

        return panel;
    }

    // 渲染快捷输入语句
    function renderQuickInputs() {
        const list = document.getElementById('doubao-quick-input-list');
        if (!list) return;

        list.innerHTML = '';
        const quickInputs = getQuickInputs();

        quickInputs.forEach((text, index) => {
            const item = document.createElement('div');
            item.style.padding = '10px';
            item.style.borderBottom = '1px solid #f0f0f0';
            item.style.cursor = 'pointer';
            item.style.display = 'flex';
            item.style.justifyContent = 'space-between';
            item.style.alignItems = 'center';

            const textSpan = document.createElement('span');
            textSpan.textContent = text;
            textSpan.style.flex = '1';
            textSpan.style.wordBreak = 'break-word';
            item.appendChild(textSpan);

            const deleteButton = document.createElement('button');
            deleteButton.textContent = '删除';
            deleteButton.style.padding = '4px 8px';
            deleteButton.style.backgroundColor = '#ff4d4f';
            deleteButton.style.color = 'white';
            deleteButton.style.border = 'none';
            deleteButton.style.borderRadius = '4px';
            deleteButton.style.cursor = 'pointer';
            deleteButton.style.fontSize = '12px';
            item.appendChild(deleteButton);

            // 点击快捷语句,将其输入到聊天框(兼容 textarea 与 contenteditable)
            textSpan.addEventListener('click', function() {
                const chatInput = document.querySelector('textarea')
                    || document.querySelector('[contenteditable="true"]')
                    || document.querySelector('[contenteditable=""]');
                if (chatInput) {
                    const tag = chatInput.tagName;
                    if (tag === 'TEXTAREA' || tag === 'INPUT') {
                        chatInput.value = text;
                    } else {
                        chatInput.innerText = text;
                    }
                    // 触发输入事件,确保目标站点能够识别输入
                    chatInput.dispatchEvent(new Event('input', { bubbles: true }));
                }
                togglePanel();
            });

            // 删除快捷语句
            deleteButton.addEventListener('click', function(e) {
                e.stopPropagation();
                const quickInputs = getQuickInputs();
                quickInputs.splice(index, 1);
                saveQuickInputs(quickInputs);
                renderQuickInputs();
            });

            list.appendChild(item);
        });
    }

    // 切换面板显示/隐藏
    function togglePanel() {
        const panel = document.getElementById('doubao-quick-input-panel');
        if (panel) {
            panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
        }
    }

    // ===================== 对话导出相关 =====================

    const site = getSiteConfig();

    // 取当前页面中可见的消息行(虚拟滚动,仅返回已渲染的)
    function getMessageRows() {
        for (const sel of site.msgRow) {
            const rows = Array.from(document.querySelectorAll(sel));
            if (rows.length) return rows;
        }
        return [];
    }

    // 找到消息列表的滚动容器(用于向上滚动触发历史懒加载)
    function findScrollContainer(el) {
        let node = el;
        while (node && node !== document.body) {
            const style = getComputedStyle(node);
            if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
                return node;
            }
            node = node.parentElement;
        }
        return null;
    }

    function scrollToTop(container) {
        if (container) container.scrollTop = 0;
        else window.scrollTo(0, 0);
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    // 部分平台(如元宝)有「加载更多」按钮,点一下可一次性载入更早的历史
    function clickLoadMore() {
        if (!site.loadMore) return;
        const btns = Array.from(document.querySelectorAll('button'));
        btns.forEach(b => {
            const t = (b.innerText || '').trim();
            if (t && /加载更多|load\s*more|查看更早|更早的消息|上一页/i.test(t)) {
                try { b.click(); } catch (e) {}
            }
        });
    }

    // 向上滚动(并点击加载更多)加载全部历史消息
    async function loadAllMessages(timeout = 7000) {
        const firstRows = getMessageRows();
        if (!firstRows.length) return;
        const container = findScrollContainer(firstRows[0]);

        const start = Date.now();
        let prevCount = 0;
        let clicked = false;
        while (Date.now() - start < timeout) {
            clickLoadMore();
            const cur = getMessageRows().length;
            if (cur === prevCount) {
                scrollToTop(container);
                await sleep(400);
                const after = getMessageRows().length;
                if (after === cur) {
                    if (!clicked) { clicked = true; clickLoadMore(); await sleep(600); continue; }
                    break;
                }
                prevCount = after;
            } else {
                prevCount = cur;
                scrollToTop(container);
                await sleep(500);
            }
        }
    }

    // 判断一条消息行的角色:user / assistant / unknown
    function getRole(row) {
        if (site.isUser(row)) return 'user';
        if (site.isAssistant(row)) return 'assistant';
        return 'unknown';
    }

    // 提取单条消息的纯文本/HTML 正文元素
    function getMessageContentEl(row) {
        return site.contentEl(row);
    }

    // 轻量 HTML -> Markdown 转换(自包含,无需外部依赖)
    function htmlToMarkdown(root) {
        if (!root) return '';
        let out = '';

        function textOf(node) { return node.textContent || ''; }

        function walk(node) {
            for (const child of node.childNodes) {
                if (child.nodeType === Node.TEXT_NODE) {
                    out += child.textContent;
                } else if (child.nodeType === Node.ELEMENT_NODE) {
                    const tag = child.tagName.toLowerCase();
                    if (tag === 'pre') {
                        out += '\n```\n' + textOf(child).replace(/\n$/, '') + '\n```\n';
                    } else if (tag === 'code') {
                        out += '`' + textOf(child) + '`';
                    } else if (tag === 'br') {
                        out += '\n';
                    } else if (tag === 'b' || tag === 'strong') {
                        out += '**' + textOf(child) + '**';
                    } else if (tag === 'i' || tag === 'em') {
                        out += '*' + textOf(child) + '*';
                    } else if (tag === 'a') {
                        const href = child.getAttribute('href') || '';
                        out += '[' + textOf(child) + '](' + href + ')';
                    } else if (tag === 'h1') {
                        out += '\n# ' + textOf(child).trim() + '\n';
                    } else if (tag === 'h2') {
                        out += '\n## ' + textOf(child).trim() + '\n';
                    } else if (tag === 'h3') {
                        out += '\n### ' + textOf(child).trim() + '\n';
                    } else if (tag === 'h4') {
                        out += '\n#### ' + textOf(child).trim() + '\n';
                    } else if (tag === 'blockquote') {
                        out += '\n> ' + textOf(child).trim().replace(/\n/g, '\n> ') + '\n';
                    } else if (tag === 'ul') {
                        out += '\n';
                        child.querySelectorAll(':scope > li').forEach(li => { out += '- ' + textOf(li).trim() + '\n'; });
                    } else if (tag === 'ol') {
                        out += '\n';
                        let i = 1;
                        child.querySelectorAll(':scope > li').forEach(li => { out += (i++) + '. ' + textOf(li).trim() + '\n'; });
                    } else if (tag === 'p') {
                        out += '\n' + textOf(child).trim() + '\n';
                    } else if (tag === 'img') {
                        const src = child.getAttribute('src') || '';
                        out += '![](' + src + ')';
                    } else if (tag === 'div' || tag === 'section') {
                        out += '\n';
                        walk(child);
                        out += '\n';
                    } else {
                        walk(child);
                    }
                }
            }
        }

        walk(root);
        // 清理多余空行
        out = out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
        return out;
    }

    // 提取全部消息为结构化数组
    function extractMessages() {
        const rows = getMessageRows();
        const messages = [];
        rows.forEach(row => {
            const role = getRole(row);
            const contentEl = getMessageContentEl(row);
            const content = htmlToMarkdown(contentEl);
            if (!content) return;
            messages.push({ role: role, content: content });
        });
        return messages;
    }

    // 生成 Markdown 文本
    function buildMarkdown(messages) {
        const label = site.label || '对话';
        const now = new Date();
        const pad = n => String(n).padStart(2, '0');
        const timeStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
        const userCount = messages.filter(m => m.role === 'user').length;

        let md = `# ${label}对话导出\n\n`;
        md += `> 导出时间:${timeStr} | 对话轮数:${userCount}\n\n`;

        messages.forEach(m => {
            const roleLabel = m.role === 'user' ? '用户' : (m.role === 'assistant' ? label : '消息');
            md += `## ${roleLabel}\n\n`;
            md += m.content + '\n\n';
            md += '---\n\n';
        });

        return md.replace(/\n---\n\n$/, '\n');
    }

    // 触发浏览器下载
    function downloadMarkdown(content) {
        const label = site.label || '对话';
        const d = new Date();
        const pad = n => String(n).padStart(2, '0');
        const filename = `${label}对话-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}.md`;
        // 加 BOM 头,保证 Windows / Excel 下中文不乱码
        const blob = new Blob(['' + content], { type: 'text/markdown; charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(() => URL.revokeObjectURL(url), 1000);
        return filename;
    }

    // 创建导出悬浮球
    function createExportBall() {
        const ball = document.createElement('div');
        ball.id = 'doubao-export-ball';
        ball.style.position = 'fixed';
        ball.style.right = '20px';
        ball.style.top = 'calc(50% + 80px)';
        ball.style.transform = 'translateY(-50%)';
        ball.style.width = '60px';
        ball.style.height = '60px';
        ball.style.borderRadius = '50%';
        ball.style.backgroundColor = '#52c41a';
        ball.style.color = 'white';
        ball.style.display = 'flex';
        ball.style.alignItems = 'center';
        ball.style.justifyContent = 'center';
        ball.style.fontSize = '24px';
        ball.style.cursor = 'pointer';
        ball.style.zIndex = '9999';
        ball.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
        ball.style.transition = 'all 0.3s ease';
        ball.title = '导出当前对话为 Markdown';
        ball.innerHTML = '📥';

        ball.addEventListener('mouseenter', function() {
            ball.style.transform = 'translateY(-50%) scale(1.1)';
        });
        ball.addEventListener('mouseleave', function() {
            ball.style.transform = 'translateY(-50%) scale(1)';
        });

        return ball;
    }

    // 轻量提示框
    function showToast(msg, duration = 2600) {
        let toast = document.getElementById('doubao-toast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'doubao-toast';
            toast.style.position = 'fixed';
            toast.style.left = '50%';
            toast.style.top = '20%';
            toast.style.transform = 'translateX(-50%)';
            toast.style.backgroundColor = 'rgba(0,0,0,0.8)';
            toast.style.color = 'white';
            toast.style.padding = '10px 18px';
            toast.style.borderRadius = '8px';
            toast.style.fontSize = '14px';
            toast.style.zIndex = '10000';
            toast.style.pointerEvents = 'none';
            toast.style.transition = 'opacity 0.3s ease';
            document.body.appendChild(toast);
        }
        toast.textContent = msg;
        toast.style.opacity = '1';
        clearTimeout(toast._timer);
        toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, duration);
    }

    // 导出对话主流程
    async function exportConversation() {
        const exportBall = document.getElementById('doubao-export-ball');
        if (exportBall) {
            exportBall.style.opacity = '0.5';
            exportBall.style.pointerEvents = 'none';
        }
        try {
            showToast('正在加载完整对话...');
            await loadAllMessages();

            const messages = extractMessages();
            if (!messages.length) {
                showToast('未找到对话内容,请确认已在对话页面', 3000);
                return;
            }

            const md = buildMarkdown(messages);
            const filename = downloadMarkdown(md);
            showToast(`导出完成:${messages.length} 条消息 → ${filename}`, 3500);
        } catch (e) {
            showToast('导出失败:' + (e && e.message ? e.message : e), 3500);
        } finally {
            if (exportBall) {
                exportBall.style.opacity = '1';
                exportBall.style.pointerEvents = 'auto';
            }
        }
    }

    // ===================== 初始化 =====================

    function init() {
        // 快捷输入悬浮球(避免重复创建)
        if (!document.getElementById('doubao-floating-ball')) {
            const ball = createFloatingBall();
            document.body.appendChild(ball);

            const panel = createQuickInputPanel();
            document.body.appendChild(panel);

            ball.addEventListener('click', togglePanel);

            document.addEventListener('click', function(e) {
                const b = document.getElementById('doubao-floating-ball');
                const p = document.getElementById('doubao-quick-input-panel');
                if (b && p && !b.contains(e.target) && !p.contains(e.target)) {
                    p.style.display = 'none';
                }
            });

            renderQuickInputs();
        }

        // 导出悬浮球(避免重复创建)
        if (!document.getElementById('doubao-export-ball')) {
            const exportBall = createExportBall();
            document.body.appendChild(exportBall);
            exportBall.addEventListener('click', exportConversation);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();