Arena++

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();