Exports the full conversation to Markdown. Uses a recursive scanner to find the chat history regardless of API changes.
// ==UserScript==
// @name Google AI Studio | Conversation/Chat Markdown-Export/Download (API-Based)
// @namespace Violentmonkey
// @version 1.5
// @description Exports the full conversation to Markdown. Uses a recursive scanner to find the chat history regardless of API changes.
// @author Vibe-Coded by Piknockyou
// @license MIT
// @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;
}
} catch (err) {
log('Interceptor Error: ' + err.message, 'error');
}
}
});
return originalSend.apply(this, arguments);
};
//================================================================================
// RECURSIVE SEARCH LOGIC (The Fix)
//================================================================================
function isTurn(arr) {
if (!Array.isArray(arr)) return false;
return arr.includes('user') || arr.includes('model');
}
function findHistoryRecursive(node, depth = 0) {
if (depth > 4) return null;
if (!Array.isArray(node)) return null;
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;
}
for (const child of node) {
if (Array.isArray(child)) {
const result = findHistoryRecursive(child, depth + 1);
if (result) return result;
}
}
return null;
}
function extractTextFromTurn(turn) {
let candidates = [];
function scan(item, d=0) {
if (d > 3) return;
if (typeof item === 'string' && item.length > 1) {
if (!['user', 'model', 'function'].includes(item)) candidates.push(item);
} else if (Array.isArray(item)) {
item.forEach(sub => scan(sub, d+1));
}
}
scan(turn.slice(0, 3));
return candidates.sort((a, b) => b.length - a.length)[0] || "";
}
//================================================================================
// PARSING & DOWNLOAD
//================================================================================
function processAndDownload() {
if (!capturedChatData) {
log('No data available yet. Nothing to download.', 'error');
updateButtonState('ERROR');
return;
}
try {
const root = capturedChatData[0];
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";
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.");
}
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`;
}
});
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');
updateButtonState('ERROR');
}
}
//================================================================================
// UI INTEGRATION
//================================================================================
function updateButtonState(state) {
if (!downloadButton || !downloadIcon) return;
downloadIcon.style.color = '#34a853'; // Always green by default
downloadButton.style.opacity = '1';
downloadButton.style.cursor = 'pointer';
downloadButton.disabled = false;
downloadButton.title = 'Download Full Chat (.md)';
switch (state) {
case 'SUCCESS':
downloadIcon.textContent = 'check_circle';
downloadButton.title = 'Export Successful!';
setTimeout(() => updateButtonState('READY'), 3000);
break;
case 'ERROR':
downloadIcon.textContent = 'error';
downloadIcon.style.color = '#ea4335';
downloadButton.title = 'No data available';
alert('No chat data available yet.\n\nPlease refresh the page and try again.');
setTimeout(() => updateButtonState('READY'), 3000);
break;
default: // READY or any other state
downloadIcon.textContent = 'download';
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);
updateButtonState('READY');
}
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 });
}
//================================================================================
// NAVIGATION HANDLER (Clears data on chat switch/new prompt)
//================================================================================
function clearCapturedData() {
capturedChatData = null;
// Button stays green - user can click anytime
}
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function() {
clearCapturedData();
return originalPushState.apply(this, arguments);
};
history.replaceState = function() {
clearCapturedData();
return originalReplaceState.apply(this, arguments);
};
window.addEventListener('popstate', clearCapturedData);
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();