Google AI Studio | Conversation/Chat Markdown-Export/Download (API-Based)

Exports the full conversation to Markdown. Uses a recursive scanner to find the chat history regardless of API changes.

当前为 2025-11-29 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Google AI Studio | Conversation/Chat Markdown-Export/Download (API-Based)
// @namespace    Violentmonkey
// @version      1.2
// @description  Exports the full conversation to Markdown. Uses a recursive scanner to find the chat history regardless of API changes.
// @author       Vibe-Coded by Expert Script Architect
// @match        https://aistudio.google.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=aistudio.google.com
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    //================================================================================
    // GLOBAL STATE
    //================================================================================
    let capturedChatData = null;
    let downloadButton = null;
    let downloadIcon = null;

    function log(msg, type = 'info') {
        const color = type === 'success' ? '#34a853' : type === 'error' ? '#ea4335' : '#e8eaed';
        console.log(`%c[AI Studio Export] ${msg}`, `color: ${color}; font-weight: bold;`);
    }

    //================================================================================
    // CORE API INTERCEPTOR
    //================================================================================
    const originalOpen = XMLHttpRequest.prototype.open;
    const originalSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function(method, url) {
        this._url = url;
        return originalOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function(body) {
        this.addEventListener('load', function() {
            if (this._url && this._url.includes('ResolveDriveResource')) {
                try {
                    const rawText = this.responseText.replace(/^\)\]\}'/, '').trim();
                    const json = JSON.parse(rawText);

                    if (Array.isArray(json) && json.length > 0) {
                        log('Data intercepted. Size: ' + rawText.length + ' chars.', 'success');
                        capturedChatData = json;
                        updateButtonState('READY');
                    }
                } catch (err) {
                    log('Interceptor Error: ' + err.message, 'error');
                }
            }
        });
        return originalSend.apply(this, arguments);
    };

    //================================================================================
    // RECURSIVE SEARCH LOGIC (The Fix)
    //================================================================================

    // Helper: Checks if an array looks like a single Chat Turn (contains "user" or "model")
    function isTurn(arr) {
        if (!Array.isArray(arr)) return false;
        // Turn arrays are usually long and have the role string near the end or beginning
        return arr.includes('user') || arr.includes('model');
    }

    // Recursive function to find the array that HOLDS the turns
    function findHistoryRecursive(node, depth = 0) {
        if (depth > 4) return null; // Safety brake
        if (!Array.isArray(node)) return null;

        // Check 1: Is THIS array the history list?
        // We look at the first few children. If THEY are turns, then THIS is the history list.
        const firstFew = node.slice(0, 5);
        const childrenAreTurns = firstFew.some(child => isTurn(child));

        if (childrenAreTurns) {
            log(`Found History at depth ${depth}. Contains ${node.length} items.`);
            return node;
        }

        // Check 2: Dig deeper
        for (const child of node) {
            if (Array.isArray(child)) {
                const result = findHistoryRecursive(child, depth + 1);
                if (result) return result;
            }
        }
        return null;
    }

    // Helper to extract text from a messy Turn array
    function extractTextFromTurn(turn) {
        let candidates = [];

        function scan(item, d=0) {
            if (d > 3) return;
            if (typeof item === 'string' && item.length > 1) {
                // Ignore metadata keywords
                if (!['user', 'model', 'function'].includes(item)) candidates.push(item);
            } else if (Array.isArray(item)) {
                item.forEach(sub => scan(sub, d+1));
            }
        }

        // Limit scan to first few elements where content usually lives
        scan(turn.slice(0, 3));

        // Return longest string found
        return candidates.sort((a, b) => b.length - a.length)[0] || "";
    }

    //================================================================================
    // PARSING & DOWNLOAD
    //================================================================================
    function processAndDownload() {
        if (!capturedChatData) {
            alert("No data captured yet. Please REFRESH the page to capture the chat.");
            return;
        }

        try {
            const root = capturedChatData[0];

            // 1. Find Title (Try known locations or fallback)
            let title = `AI_Studio_Export_${new Date().toISOString().slice(0,10)}`;
            if (Array.isArray(root[4]) && typeof root[4][0] === 'string') title = root[4][0];

            const safeFilename = title.replace(/[<>:"/\\|?*]/g, '_').trim().substring(0, 100) + ".md";

            // 2. Find History using Recursive Search
            const historyArray = findHistoryRecursive(root);

            if (!historyArray) {
                console.warn("Recursive search failed. dumping root:", root);
                throw new Error("Could not locate chat history in API response.");
            }

            // 3. Build Markdown
            let mdContent = `# ${title}\n\n`;

            historyArray.forEach((turn) => {
                const isUser = turn.includes('user');
                const isModel = turn.includes('model');
                const displayName = isUser ? 'USER' : (isModel ? 'MODEL' : 'UNKNOWN');

                let text = extractTextFromTurn(turn);

                if (text) {
                    mdContent += `### **${displayName}**\n\n${text}\n\n---\n\n`;
                }
            });

            // 4. Download
            const blob = new Blob([mdContent], { type: 'text/markdown;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = safeFilename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);

            updateButtonState('SUCCESS');

        } catch (e) {
            log('Parsing failed: ' + e.message, 'error');
            alert('Export Failed: ' + e.message);
            updateButtonState('ERROR');
        }
    }

    //================================================================================
    // UI INTEGRATION
    //================================================================================
    function updateButtonState(state) {
        if (!downloadButton || !downloadIcon) return;
        downloadIcon.style.color = '';

        switch (state) {
            case 'WAITING':
                downloadIcon.textContent = 'downloading';
                downloadIcon.style.color = '#9aa0a6';
                downloadButton.title = 'Waiting for data... (Please Refresh Page)';
                break;
            case 'READY':
                downloadIcon.textContent = 'download';
                downloadIcon.style.color = '#34a853';
                downloadButton.title = 'Download Full Chat (.md)';
                break;
            case 'SUCCESS':
                downloadIcon.textContent = 'check_circle';
                downloadButton.title = 'Export Successful!';
                setTimeout(() => updateButtonState('READY'), 3000);
                break;
            case 'ERROR':
                downloadIcon.textContent = 'error';
                downloadIcon.style.color = '#ea4335';
                setTimeout(() => updateButtonState('READY'), 3000);
                break;
        }
    }

    function createUI() {
        const toolbarRight = document.querySelector('ms-toolbar .toolbar-right');
        if (!toolbarRight || document.getElementById('aistudio-api-export-btn')) return;

        log('Injecting Native Toolbar Button...');

        const buttonContainer = document.createElement('div');
        buttonContainer.style.cssText = 'display: flex; align-items: center; margin: 0 4px; position: relative;';

        downloadButton = document.createElement('button');
        downloadButton.id = 'aistudio-api-export-btn';
        downloadButton.setAttribute('ms-button', '');
        downloadButton.setAttribute('variant', 'icon-borderless');
        downloadButton.className = 'mat-mdc-tooltip-trigger ms-button-borderless ms-button-icon';
        downloadButton.style.cursor = 'pointer';

        downloadIcon = document.createElement('span');
        downloadIcon.className = 'material-symbols-outlined notranslate ms-button-icon-symbol';
        downloadIcon.textContent = 'download';

        downloadButton.appendChild(downloadIcon);
        buttonContainer.appendChild(downloadButton);

        const moreButton = toolbarRight.querySelector('button[iconname="more_vert"]');
        if (moreButton) {
            toolbarRight.insertBefore(buttonContainer, moreButton);
        } else {
            toolbarRight.appendChild(buttonContainer);
        }

        downloadButton.addEventListener('click', processAndDownload);

        if (capturedChatData) updateButtonState('READY');
        else updateButtonState('WAITING');
    }

    function initialize() {
        createUI();
        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList') {
                    const toolbar = document.querySelector('ms-toolbar .toolbar-right');
                    if (toolbar) createUI();
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

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

})();