Arena++

提取 Arena.ai 对话(支持公式/MD/选区复制)

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Arena++
// @name:en      Arena++
// @name:ja      Arena++
// @name:ru      Arena++
// @namespace    http://tampermonkey.net/
// @version      1.2.1
// @description  提取 Arena.ai 对话(支持公式/MD/选区复制)
// @description:en  Extract the Arena.ai dialog (supports formula/MD/selection copying)
// @description:ja  Arena.ai ダイアログを抽出します (式/MD/選択のコピーをサポート)
// @description:ru  Распакуйте диалоговое окно Arena.ai (поддерживает копирование формул/MD/выделений).
// @author       Panda is cat
// @match        https://arena.ai/*
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ================= I18N (多语言配置) =================
    const userLang = navigator.language.startsWith('zh') ? 'zh' : 'en';

    const I18N = {
        zh: {
            mode_on: "🖱️ 选择模式: 开",
            mode_off: "🖱️ 选择模式: 关",
            copy_btn: "📋 提取并复制",
            copy_done: "✅ 已复制!",
            alert_empty: "❌ 未找到消息或未选中任何内容!",
            role_user: "User",
            role_ai_default: "AI"
        },
        en: {
            mode_on: "🖱️ Select Mode: ON",
            mode_off: "🖱️ Select Mode: OFF",
            copy_btn: "📋 Extract & Copy",
            copy_done: "✅ Copied!",
            alert_empty: "❌ No messages found or selected!",
            role_user: "User",
            role_ai_default: "AI"
        }
    };
    const TEXT = I18N[userLang] || I18N.en;

    // ================= 样式配置 =================
    const CONFIG = {
        highlightColor: 'rgba(121, 219, 143, 0.3)',
        borderColor: '#10a37f',
        buttonColor: '#3b82f6'
    };

    GM_addStyle(`
        #ace-floating-panel {
            position: fixed; bottom: 20px; right: 20px; z-index: 9999;
            background: #fff; padding: 10px; border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-family: sans-serif;
            display: flex; gap: 8px; border: 1px solid #e5e7eb;
        }
        .ace-btn {
            padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer;
            font-size: 13px; font-weight: 600; transition: all 0.2s; color: white;
        }
        #ace-toggle-select { background-color: #6b7280; }
        #ace-toggle-select.active { background-color: ${CONFIG.borderColor}; }
        #ace-copy-btn { background-color: ${CONFIG.buttonColor}; }
        #ace-copy-btn:hover { opacity: 0.9; }

        .ace-selectable { cursor: pointer !important; position: relative; transition: all 0.2s; }
        .ace-selectable:hover { box-shadow: 0 0 0 2px rgba(0,0,0,0.1); }
        .ace-selected {
            background-color: ${CONFIG.highlightColor} !important;
            box-shadow: 0 0 0 2px ${CONFIG.borderColor} !important;
            border-radius: 6px;
        }
        .ace-selected * { pointer-events: none; }
    `);

    // ================= 核心类 =================
    class ChatExporter {
        constructor() {
            this.selectionMode = false;
            this.messages = [];
            this.selectedSet = new Set();
            this.initUI();
        }

        initUI() {
            const panel = document.createElement('div');
            panel.id = 'ace-floating-panel';
            panel.innerHTML = `
                <button id="ace-toggle-select" class="ace-btn">${TEXT.mode_off}</button>
                <button id="ace-copy-btn" class="ace-btn">${TEXT.copy_btn}</button>
            `;
            document.body.appendChild(panel);

            this.btnToggle = panel.querySelector('#ace-toggle-select');
            this.btnCopy = panel.querySelector('#ace-copy-btn');

            this.btnToggle.addEventListener('click', () => this.toggleSelectionMode());
            this.btnCopy.addEventListener('click', () => this.extractAndCopy());
        }

        async toggleSelectionMode() {
            this.selectionMode = !this.selectionMode;
            this.btnToggle.textContent = this.selectionMode ? TEXT.mode_on : TEXT.mode_off;
            this.btnToggle.classList.toggle('active', this.selectionMode);

            await this.scanMessages();

            if (this.selectionMode) {
                this.messages.forEach((msg) => {
                    msg.element.classList.add('ace-selectable');
                    msg.element.onclick = (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        this.toggleMessageSelection(msg.element);
                    };
                });
                if(this.messages.length > 0) return;
            } else {
                this.messages.forEach(msg => {
                    msg.element.classList.remove('ace-selectable', 'ace-selected');
                    msg.element.onclick = null;
                });
                this.selectedSet.clear();
            }
        }

        toggleMessageSelection(element) {
            if (element.classList.contains('ace-selected')) {
                element.classList.remove('ace-selected');
                this.selectedSet.delete(element);
            } else {
                element.classList.add('ace-selected');
                this.selectedSet.add(element);
            }
        }

        /**
         * 核心重构:消息扫描逻辑
         * 不再依赖双栏坐标,而是根据 DOM 特征(bg-surface-raised)和局部查找
         */
        async scanMessages() {
            // 1. 获取所有可能是消息内容的块
            // .prose 是 markdown 内容的标准容器,.message-content 是某些版本的容器
            const rawElements = Array.from(document.querySelectorAll('.prose, .message-content, [data-testid="chatbot"] .markdown'));

            if (rawElements.length === 0) return;

            this.messages = rawElements.map(el => {
                const rect = el.getBoundingClientRect();

                let role = "Unknown";
                let displayName = TEXT.role_ai_default;

                // --- 步骤 1: 判定是否为用户 (基于你提供的 bg-surface-raised 类) ---
                // 用户对话框特征:<div class="bg-surface-raised ..."><div class="prose">...</div></div>
                // 向上查找最近的 bg-surface-raised
                const userContainer = el.closest('.bg-surface-raised');

                if (userContainer) {
                    role = "User";
                    displayName = TEXT.role_user;
                } else {
                    // --- 步骤 2: 如果不是用户,则为 AI,寻找模型名称 ---
                    role = "AI";

                    // 从当前元素向上寻找容器,然后在容器内寻找 span.truncate
                    // 向上遍历 5 层足够覆盖大多数层级
                    let parent = el.parentElement;
                    let foundName = null;

                    for(let i=0; i<6; i++) {
                        if (!parent) break;

                        // 在父级内查找包含 truncate 的 span
                        const nameSpan = parent.querySelector('span.truncate');

                        // 确保找到的 nameSpan 不是消息内容本身的一部分,而是 header
                        if (nameSpan && nameSpan.innerText && !el.contains(nameSpan)) {
                            const txt = nameSpan.innerText.trim();
                            // 排除空文本或明显不是模型名的文本
                            if (txt.length > 0 && txt.toLowerCase() !== 'user') {
                                foundName = txt;
                                break; // 找到了就停止
                            }
                        }
                        parent = parent.parentElement;
                    }

                    if (foundName) {
                        displayName = foundName;
                    }
                }

                return {
                    element: el,
                    top: rect.top, // 依然保留 Top 用于按屏幕顺序排序
                    role: role,
                    name: displayName
                };
            });

            // 3. 排序:确保消息按视觉顺序排列 (解决 DOM 乱序问题)
            this.messages.sort((a, b) => a.top - b.top);
        }

        elementToMarkdown(element) {
            if (!element) return "";
            const clone = element.cloneNode(true);

            // 修复 KaTeX
            clone.querySelectorAll('annotation[encoding="application/x-tex"]').forEach(node => {
                const tex = node.textContent.trim();
                const container = node.closest('.katex');
                if (container) {
                    const isBlock = container.classList.contains('display') ||
                                    window.getComputedStyle(container).display === 'block';
                    container.parentNode.replaceChild(document.createTextNode(isBlock ? `\n$$\n${tex}\n$$\n` : `$${tex}$`), container);
                }
            });

            // 修复代码块
            clone.querySelectorAll('pre').forEach(pre => {
                const code = pre.querySelector('code');
                let lang = "";
                if (code) {
                    code.classList.forEach(cls => {
                        if (cls.startsWith('language-')) lang = cls.replace('language-', '');
                    });
                }
                pre.replaceWith(`\n\`\`\`${lang}\n${pre.innerText}\n\`\`\`\n`);
            });

            // 基础格式
            clone.querySelectorAll('b, strong').forEach(e => e.textContent = `**${e.textContent}**`);
            clone.querySelectorAll('i, em').forEach(e => e.textContent = `*${e.textContent}*`);
            clone.querySelectorAll('a').forEach(e => e.textContent = `[${e.textContent}](${e.href})`);

            ['h1','h2','h3','h4'].forEach((t, i) => {
                clone.querySelectorAll(t).forEach(e => e.textContent = `\n${'#'.repeat(i+1)} ${e.textContent}\n`);
            });
            clone.querySelectorAll('li').forEach(li => li.prepend('- '));

            return clone.innerText.replace(/\n{3,}/g, '\n\n').trim();
        }

        async extractAndCopy() {
            await this.scanMessages();

            let targetMessages = this.messages;
            if (this.selectionMode && this.selectedSet.size > 0) {
                targetMessages = this.messages.filter(msg => this.selectedSet.has(msg.element));
            }

            if (targetMessages.length === 0) {
                alert(TEXT.alert_empty);
                return;
            }

            let output = "";
            let lastRole = ""; // 用于去重 header

            targetMessages.forEach(msg => {
                const text = this.elementToMarkdown(msg.element);
                if (!text) return;

                // 只有当名字变化时,才打印 Header (可选,这里为了清晰每次都打印,或你可以取消注释下面的逻辑)
                // if (msg.name !== lastRole) {
                    output += `\n---\n### **${msg.name}**:\n\n`;
                    lastRole = msg.name;
                // } else {
                //    output += `\n\n`;
                // }

                output += text + "\n";
            });

            try {
                if (typeof GM_setClipboard !== 'undefined') {
                    GM_setClipboard(output);
                } else {
                    await navigator.clipboard.writeText(output);
                }

                const btnOriginalText = this.btnCopy.textContent;
                this.btnCopy.textContent = TEXT.copy_done;
                this.btnCopy.style.backgroundColor = CONFIG.borderColor;
                setTimeout(() => {
                    this.btnCopy.textContent = btnOriginalText;
                    this.btnCopy.style.backgroundColor = CONFIG.buttonColor;
                }, 2000);
            } catch (err) {
                console.error(err);
                alert("Copy failed.");
            }
        }
    }

    // 延迟启动
    setTimeout(() => {
        new ChatExporter();
    }, 2500);

})();