Exports the full conversation to Markdown. Uses a recursive scanner to find the chat history regardless of API changes.
当前为
// ==UserScript==
// @name Google AI Studio | Chat/Conversation Markdown-Export (API Based)
// @namespace Violentmonkey
// @version 1.1
// @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();
}
})();