Export AI Studio conversations to Markdown with intelligent mode detection, toolbar integration, and abortable processing. Features dual-mode extraction and configurable filters.
// ==UserScript== // @name Google AI Studio | Chat MarkDown-Export // @namespace http://violentmonkey.net/ // @version 2.4 // @description Export AI Studio conversations to Markdown with intelligent mode detection, toolbar integration, and abortable processing. Features dual-mode extraction and configurable filters. // @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 GM_addStyle // @run-at document-idle // ==/UserScript== /** * AI Studio Export Script - Advanced Conversation Capture Tool * ============================================================ * * A comprehensive userscript that exports Google AI Studio conversations to Markdown format * with intelligent mode detection, automated Raw Mode toggle, and high-performance scrolling. * * CREDITS & INSPIRATION * --------------------- * This script builds upon concepts from two projects: * * 1. Userscript: Google AI Studio 聊天记录导出器** (now deleted) * • @author: qwerty * • Uploader: https://greasyfork.org/en/users/1462896-max-tab * • Script: https://greasyfork.org/en/scripts/534177-google-ai-studio-chat-record-exporter * * 2. Extension: Gemini to PDF * • Developer: Hamza Wasim ([email protected]) * • Extension: https://chromewebstore.google.com/detail/gemini-to-pdf/blndbnmpkgfoopgmcejnhdnepfejgipe * • Website: https://geminitoolbox.com/ * • Announcement: https://www.reddit.com/r/Bard/comments/1lshanb/built_a_free_extension_to_save_gemini_and_ai/ * * CORE FEATURES * ------------- * • Intelligent Mode Detection: Auto-detects Raw vs Rendered Mode * • Abortable Processing: Cancel extraction mid-process * • Toolbar Integration: Seamlessly integrated into AI Studio's toolbar * • Configurable Filters: Real-time control over export content * • High-Performance: 50ms scroll delays with smart batching * • Auto-Recovery: Handles dynamic UI re-rendering automatically * • CSP-Compliant: Full compliance with AI Studio's security policies * * USER INTERFACE * -------------- * • Export Button: Toolbar icon with visual state feedback (Idle/Working/Success/Error) * • Settings Panel: Gear icon opens configuration with export filters * • Abort Control: Click button during processing to cancel operation * • Dynamic Positioning: Panel adapts to window changes * * CONFIGURATION * ------------- * • Prefer Raw Mode: Auto-switch to Raw Mode for cleaner extraction * • Include User Messages: Control user prompt inclusion * • Include AI Responses: Control AI answer inclusion * • Include AI Thinking: Control AI reasoning/thought process inclusion * * TECHNICAL ARCHITECTURE * ---------------------- * • MutationObserver: Watches toolbar changes for auto-recovery * • AbortController: Implements responsive cancellation * • Multi-Strategy DOM: Fallback selectors for reliable extraction * • Memory Efficient: DOM element keys for data association * • Scroll Intelligence: Advanced detection of conversation boundaries * * USAGE * ----- * 1. Find export/download icon in AI Studio toolbar * 2. Click gear icon to configure export filters (optional) * 3. Click export button to begin automated capture * 4. Monitor progress via button state changes * 5. Click button again to abort if needed * 6. Download starts automatically when complete * * PERFORMANCE * ----------- * • Ultra-Fast: 50ms scroll delays with intelligent batching * • Smart Detection: Multiple passes ensure complete capture * • Error Recovery: Automatic retry with graceful failure handling * • Browser Support: Compatible with Tampermonkey, Violentmonkey */ (function() { 'use strict'; //================================================================================ // CONFIGURATION - All script settings, constants, and tunable parameters //================================================================================ //-------------------------------------------------------------------------------- // CORE EXPORT PREFERENCES - The most important settings for the user //-------------------------------------------------------------------------------- // Mode Preference: Set to true to extract in "Raw Mode" (clean markdown), or false for "Rendered Mode". let PREFER_RAW_MODE = true; // Content Filtering: Control which parts of the conversation to include in the export. let INCLUDE_USER_MESSAGES = true; // true to include user prompts/questions let INCLUDE_AI_RESPONSES = true; // true to include the AI's main answers/replies let INCLUDE_AI_THINKING = false; // true to include the AI's reasoning/thought process //-------------------------------------------------------------------------------- // FILE OUTPUT SETTINGS - Customize the exported file name //-------------------------------------------------------------------------------- const EXPORT_FILENAME_PREFIX = 'aistudio_chat_export_'; //-------------------------------------------------------------------------------- // CORE EXTRACTION BEHAVIOR - Settings that affect how content is captured //-------------------------------------------------------------------------------- const THOUGHT_EXPAND_DELAY_MS = 500; // Wait time after expanding "thinking" sections to allow content to load. const THOUGHT_EXPANSION_RETRY_COUNT = 3; // Number of attempts to expand thinking sections if the first try fails. const THOUGHT_MIN_LENGTH = 10; // Minimum text length for a "thinking" block to be considered valid. //-------------------------------------------------------------------------------- // PERFORMANCE & TIMING - Advanced settings to balance speed and reliability //-------------------------------------------------------------------------------- const SCROLL_DELAY_MS = 50; // Main scroll delay: 50ms for "machine-gun" speed. Lower values are faster but may miss content on slow connections. const INITIAL_SCROLL_DELAY = 100; // Initial delay for page content to stabilize before scrolling begins. const RAW_MODE_MENU_DELAY_MS = 200; // Wait for the "Raw Mode" dropdown menu to appear after clicking "More". const RAW_MODE_RENDER_DELAY_MS = 300; // Wait for the UI to re-render after toggling Raw Mode. const FINAL_CAPTURE_DELAY_MS = 50; // Delay before the final data extraction pass after scrolling is complete. const POST_EXTRACTION_DELAY_MS = 100; // Delay for any cleanup or final processing after extraction. const FINAL_COLLECTION_DELAY_MS = 300; // Delay at each final scroll position (top, middle, bottom) to ensure all content is loaded. const SUCCESS_RESET_TIMEOUT_MS = 2500; // Time in ms before the button resets from 'Success' to 'Idle' (2.5 seconds). const ERROR_RESET_TIMEOUT_MS = 4000; // Time in ms before the button resets from 'Failure' to 'Idle' (4 seconds). const SCRIPT_INIT_DELAY_MS = 2500; // Delay after the page loads before the script initializes (2.5 seconds). const UPWARD_SCROLL_DELAY_MS = 1000; // Delay for content to load when starting a scroll-up operation from the bottom. //-------------------------------------------------------------------------------- // SCROLL BEHAVIOR - Advanced parameters controlling the auto-scrolling mechanism //-------------------------------------------------------------------------------- const MAX_SCROLL_ATTEMPTS = 800; // Maximum scroll attempts before giving up to prevent infinite loops. const SCROLL_INCREMENT_INITIAL = 150; // Initial scroll increment in pixels per step. const SCROLL_INCREMENT_LARGE = 500; // A larger initial jump for the first scroll pass to load content faster. const SCROLL_INCREMENT_FACTOR = 0.85; // Reduction factor for the scroll increment (e.g., 85% of the previous increment). const BOTTOM_DETECTION_TOLERANCE = 10; // Pixel tolerance for detecting the bottom of the scroll area. const PROBLEMATIC_JUMP_FACTOR = 1.25; // Factor to detect problematic scroll jumps (e.g., 125% of intended distance). const MIN_SCROLL_DISTANCE_THRESHOLD = 5; // Minimum pixel distance to detect if scrolling has effectively stopped. const SCROLL_PARENT_SEARCH_DEPTH = 5; // How many parent elements to search upwards to find the main scroll container. const SCROLL_STABILITY_CHECKS = 3; // Number of consecutive checks to confirm scroll stability before stopping. //-------------------------------------------------------------------------------- // UI TEXT & LABELS - Customizable text for UI elements //-------------------------------------------------------------------------------- const BUTTON_TEXT_START_SCROLL_DOWN = "Start Export"; const BUTTON_TEXT_STOP_SCROLL = "Stop Export"; const BUTTON_TEXT_PROCESSING = "Exporting..."; const SUCCESS_TEXT_EXPORT = "Export Complete!"; const ERROR_TEXT_EXPORT = "Export Failed"; //-------------------------------------------------------------------------------- // UI STYLING & POSITIONING - Detailed CSS-like values for the main button //-------------------------------------------------------------------------------- const BUTTON_Z_INDEX = 10000; const BUTTON_POSITION_BOTTOM = 20; const BUTTON_POSITION_LEFT = 20; const BUTTON_PADDING_VERTICAL = 12; const BUTTON_PADDING_HORIZONTAL = 20; const BUTTON_BORDER_RADIUS = 25; const BUTTON_FONT_SIZE = 14; const BUTTON_FONT_WEIGHT = 500; const BUTTON_BOX_SHADOW_BLUR = 8; const BUTTON_TRANSITION_DURATION = 0.2; const BUTTON_BOX_SHADOW_RGBA = 'rgba(0,0,0,0.2)'; const BUTTON_DEFAULT_BG = '#1a73e8'; const BUTTON_PROCESSING_BG = '#1a73e8'; const BUTTON_SUCCESS_BG = '#1e8e3e'; const BUTTON_ERROR_BG = '#d93025'; const BUTTON_FONT_FAMILY = "'Google Sans', sans-serif"; const BUTTON_COLOR = 'white'; const BUTTON_BORDER = 'none'; const BUTTON_CURSOR = 'pointer'; //-------------------------------------------------------------------------------- // LOG CONSOLE UI - Styling for the debug log panel //-------------------------------------------------------------------------------- const LOG_ENTRY_INFO_COLOR = '#e8eaed'; const LOG_ENTRY_SUCCESS_COLOR = '#34a853'; const LOG_ENTRY_WARN_COLOR = '#fbbc04'; const LOG_ENTRY_ERROR_COLOR = '#ea4335'; // State variables let isScrolling = false; let collectedData = new Map(); let scrollCount = 0; let noChangeCounter = 0; let scrollIncrement = SCROLL_INCREMENT_LARGE; let exportButtonState = 'IDLE'; // Can be 'IDLE', 'WORKING', 'SUCCESS', 'ERROR' let abortController; // UI Elements let exportButton, exportIcon; let settingsPanel; let preferRawModeCheckbox, includeUserMessagesCheckbox, includeAiResponsesCheckbox, includeAiThinkingCheckbox; //================================================================================ // HELPER FUNCTIONS - Utility functions for script operation //================================================================================ /** * Creates a Trusted Types policy to handle Content Security Policy compliance * for DOM manipulation in Google AI Studio's environment. */ let trustedTypesPolicy = null; if (window.trustedTypes && window.trustedTypes.createPolicy) { try { // Policy names for userscripts often require a specific format. trustedTypesPolicy = window.trustedTypes.createPolicy('aistudio-export-policy#userscript', { createHTML: string => string }); } catch (e) { console.warn("[AI Studio Export] Could not create a new Trusted Types policy. This might happen if the policy already exists. The script will attempt to continue.", e.message); } } /** * Creates a trusted HTML string for DOM manipulation while respecting CSP policies. * @param {string} htmlString - The HTML string to be processed. * @returns {string} - A trusted HTML string compatible with Content Security Policy. */ function getTrustedHTML(htmlString) { if (trustedTypesPolicy) { return trustedTypesPolicy.createHTML(htmlString); } return htmlString; } /** * Creates a delay/promise for asynchronous operations. * @param {number} ms - Time in milliseconds to delay. * @returns {Promise} - Promise that resolves after the specified delay. */ function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Generates a timestamp string in format YYYYMMDD_HHMMSS for file naming. * @returns {string} - Formatted timestamp string. */ function getCurrentTimestamp() { const n = new Date(); const YYYY = n.getFullYear(); const MM = (n.getMonth() + 1).toString().padStart(2, '0'); const DD = n.getDate().toString().padStart(2, '0'); const hh = n.getHours().toString().padStart(2, '0'); const mm = n.getMinutes().toString().padStart(2, '0'); const ss = n.getSeconds().toString().padStart(2, '0'); return `${YYYY}${MM}${DD}_${hh}${mm}${ss}`; } /** * Logs a message to the browser console with consistent formatting. * @param {string} message - The message to log. */ function logToConsole(message) { console.log(`[AI Studio Export] ${message}`); } /** * Logs a message to both console and visual panel for consistent output. * This function replaces the old logToConsole calls throughout the script. * @param {string} message - The message to log. * @param {string} type - The log type: 'info', 'success', 'warn', 'error' (optional). */ function log(message, type = 'info') { const style = `color: ${type === 'success' ? LOG_ENTRY_SUCCESS_COLOR : type === 'warn' ? LOG_ENTRY_WARN_COLOR : type === 'error' ? LOG_ENTRY_ERROR_COLOR : LOG_ENTRY_INFO_COLOR};`; console.log(`%c[AI Studio Export] ${message}`, style); } /** * Detects whether the page is currently in Raw Mode or Rendered Mode. * Raw Mode shows plain markdown text in .very-large-text-container * Rendered Mode shows formatted content in ms-cmark-node elements * @returns {string} - 'raw' or 'rendered' */ function detectCurrentMode() { // Strategy 1: Check for Raw Mode indicator in a user turn const firstUserTurn = document.querySelector('ms-chat-turn .chat-turn-container.user'); if (firstUserTurn) { const hasRawContainer = firstUserTurn.querySelector('ms-text-chunk .very-large-text-container'); const hasCmarkNode = firstUserTurn.querySelector('ms-text-chunk ms-cmark-node'); if (hasRawContainer && !hasCmarkNode) { log("Detected mode: Raw Mode", 'info'); return 'raw'; } if (hasCmarkNode && !hasRawContainer) { log("Detected mode: Rendered Mode", 'info'); return 'rendered'; } } // Strategy 2: Check URL or page state // (AI Studio might have a parameter or class indicating mode) const body = document.body; if (body.classList.contains('raw-mode')) { log("Detected mode: Raw Mode (via body class)", 'info'); return 'raw'; } // Default assumption (most common state) log("Could not detect mode, assuming Rendered Mode", 'warn'); return 'rendered'; } /** * Expands collapsed AI thinking sections to expose hidden content. * Uses multiple selector strategies to find and expand thinking panels. * @async * @param {Element} modelDiv - The model DOM element to expand thinking in. * @param {number} turnIndex - The index of the turn being processed. * @returns {Promise<boolean>} - True if thinking sections were expanded, false otherwise. */ async function expandThinkingSections(modelDiv, turnIndex = 0) { let expanded = false; try { // Strategy 1: Find collapsed expansion panels with thought-related text const collapsedPanels = modelDiv.querySelectorAll('mat-expansion-panel[aria-expanded="false"]'); for (const panel of collapsedPanels) { const headerText = panel.querySelector('.mat-expansion-panel-header-title')?.textContent?.toLowerCase() || ''; const buttonText = panel.querySelector('button[aria-expanded="false"]')?.textContent?.toLowerCase() || ''; if (headerText.includes('thought') || headerText.includes('thinking') || buttonText.includes('thought') || buttonText.includes('thinking')) { const expandButton = panel.querySelector('button[aria-expanded="false"]'); if (expandButton) { expandButton.click(); expanded = true; log(`Expanded thinking section (panel method) for turn ${turnIndex}`, 'info'); } } } // Strategy 2: Find expand buttons with thought-related text const expandButtons = modelDiv.querySelectorAll('button'); for (const button of expandButtons) { const buttonText = button.textContent?.toLowerCase() || ''; if ((buttonText.includes('expand') || buttonText.includes('show more')) && buttonText.includes('thought')) { button.click(); expanded = true; log(`Expanded thinking section (button method) for turn ${turnIndex}`, 'info'); } } // Strategy 3: Click any "Show more" buttons in thought chunks const thoughtChunks = modelDiv.querySelectorAll('ms-thought-chunk'); for (const chunk of thoughtChunks) { const showMoreButton = chunk.querySelector('button[aria-expanded="false"], button:not([aria-expanded])'); if (showMoreButton && showMoreButton.textContent?.toLowerCase().includes('more')) { showMoreButton.click(); expanded = true; log(`Expanded thinking section (chunk method) for turn ${turnIndex}`, 'info'); } } // Wait for expansion to complete if (expanded) { await delay(THOUGHT_EXPAND_DELAY_MS); } return expanded; } catch (error) { log(`Error expanding thinking sections for turn ${turnIndex}: ${error.message}`, 'warn'); return false; } } /** * Clean and validate HTML snippet, removing unwanted elements. * Uses CSP-compliant DOM parsing to handle AI Studio's strict Content Security Policy. * @param {string} htmlSnippet - The HTML content to be cleaned and validated. * @returns {string|null} - The cleaned HTML string, or null if validation fails. */ function cleanAndValidateSnippet(htmlSnippet) { if (!htmlSnippet || "string" !== typeof htmlSnippet) { return null; } let docFragment; try { // Use Range.createContextualFragment to safely parse HTML, avoiding DOMParser CSP issues. const range = document.createRange(); range.selectNode(document.body); // Set a context for parsing. docFragment = range.createContextualFragment(htmlSnippet); } catch (parseError) { console.error("[AI Studio Export] Error creating contextual fragment during cleaning:", parseError, htmlSnippet.substring(0, 100)); return null; // Return null if parsing fails } const turnDiv = docFragment.firstElementChild; if (!turnDiv) { console.warn("[AI Studio Export] Cleaning: Snippet did not contain a valid root element.", htmlSnippet.substring(0, 100)); return null; } // Skip HTML cleaning due to strict CSP. Return raw HTML for fallback. // This method is CSP-compliant and won't trigger TrustedHTML errors. return htmlSnippet; } /** * Convert HTML to Markdown format with comprehensive formatting support. * Handles code blocks, links, images, inline code, emphasis, lists, headings, and paragraphs. * @param {string} html - The HTML content to convert to Markdown. * @returns {string} - The converted Markdown string. */ function convertToMarkdown(html) { if (!html) return ""; try { const tempDiv = document.createElement("div"); tempDiv.innerHTML = getTrustedHTML(html); const ignoreStartRegex = /^\s*<!--\s*IGNORE_WHEN_COPYING_START\s*-->\s*(\r?\n)?/im; const ignoreEndRegex = /^\s*<!--\s*IGNORE_WHEN_COPYING_END\s*-->\s*(\r?\n)?/im; const sourceElement = tempDiv.querySelector(".turn-content") || tempDiv; // Process code blocks sourceElement.querySelectorAll("ms-code-block").forEach(block => { const pre = block.querySelector("pre"); if (pre) { pre.innerHTML = getTrustedHTML(pre.innerHTML.replaceAll(ignoreStartRegex, "").replaceAll(ignoreEndRegex, "")); } const code = pre ? pre.textContent : block.textContent.replaceAll(ignoreStartRegex, "").replaceAll(ignoreEndRegex, ""); const langMatch = code.match(/^(\w+)\s*\n/); const lang = langMatch ? langMatch[1] : ""; const codeContent = langMatch ? code.substring(langMatch[0].length) : code; block.replaceWith(`\n\n\`\`\`${lang}\n${codeContent.trim()}\n\`\`\`\n\n`); }); // Process links sourceElement.querySelectorAll("a").forEach(a => { a.replaceWith(`[${a.textContent || a.innerText || ""}](${a.getAttribute("href") || ""})`); }); // Process images sourceElement.querySelectorAll("img").forEach(img => { img.replaceWith(`\n\n || ""})\n\n`); }); // Process inline code sourceElement.querySelectorAll("code:not(pre code)").forEach(code => { code.replaceWith(`\`${code.textContent}\``); }); // Process emphasis sourceElement.querySelectorAll("strong, b").forEach(el => { el.replaceWith(`**${el.textContent}**`); }); sourceElement.querySelectorAll("em, i").forEach(el => { el.replaceWith(`*${el.textContent}*`); }); // Process lists sourceElement.querySelectorAll("ul, ol").forEach(list => { let listMd = "\n"; Array.from(list.children).forEach((li, index) => { let liContent = li.innerHTML.replace(/<br\s*\/?>/gi, "\n"); const tempLiDiv = document.createElement("div"); tempLiDiv.innerHTML = getTrustedHTML(liContent); // Clean formatting within list items tempLiDiv.querySelectorAll("strong, b").forEach(el => { el.replaceWith(`**${el.textContent}**`); }); tempLiDiv.querySelectorAll("em, i").forEach(el => { el.replaceWith(`*${el.textContent}*`); }); tempLiDiv.querySelectorAll("code:not(pre code)").forEach(el => { el.replaceWith(`\`${el.textContent}\``); }); tempLiDiv.querySelectorAll("a").forEach(el => { el.replaceWith(`[${el.textContent}](${el.getAttribute("href")})`); }); const content = tempLiDiv.textContent?.trim() || ""; listMd += `${list.tagName === "OL" ? index + 1 + "." : "*"} ${content}\n`; }); list.replaceWith(listMd); }); // Process headings sourceElement.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach(h => { const level = parseInt(h.tagName.substring(1), 10); h.replaceWith(`\n\n${"#".repeat(level)} ${h.textContent}\n\n`); }); // Process paragraphs sourceElement.querySelectorAll("p").forEach(p => { const next = p.nextElementSibling; if (!p.textContent.trim() || (next && ["H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "PRE", "BLOCKQUOTE", "HR", "TABLE", "MS-CODE-BLOCK"].includes(next?.tagName))) { return; // Skip empty paragraphs and those followed by block elements } p.after("\n\n"); }); let text = sourceElement.innerText || sourceElement.textContent || ""; return text = text.replace(/(\n\s*){3,}/g, "\n\n"), text.trim(); } catch (e) { console.error("Error converting HTML snippet to Markdown:", e, html.substring(0, 100)); const tempDiv = document.createElement("div"); tempDiv.innerHTML = getTrustedHTML(html); return tempDiv.textContent || ""; } } //================================================================================ // CORE FUNCTIONS - Main business logic and automation //================================================================================ /** * Automates the Raw Mode toggle functionality in Google AI Studio. * This function finds and clicks the "more actions" button, then selects "Raw Mode" * from the dropdown menu to expose the pure Markdown content. * @async * @returns {Promise<boolean>} - True if Raw Mode was successfully toggled, false otherwise. */ async function toggleRawMode() { log("Attempting to toggle Raw Mode..."); // 1. Find and click the 'More' button const moreButton = document.querySelector('button[aria-label="View more actions"]'); if (!moreButton) { log("Error: 'More actions' button not found.", 'error'); return false; } moreButton.click(); log("'More actions' button clicked."); // 2. Wait for the menu to appear and click the 'Raw Mode' button await delay(RAW_MODE_MENU_DELAY_MS); // Wait for menu animation const rawModeButton = Array.from(document.querySelectorAll('button[role="menuitem"]')) .find(btn => btn.textContent.includes('Raw Mode')); if (!rawModeButton) { log("Error: 'Raw Mode' button not found in the menu.", 'error'); // Attempt to close the menu by clicking the 'More' button again moreButton.click(); return false; } rawModeButton.click(); log("'Raw Mode' button clicked."); await delay(RAW_MODE_RENDER_DELAY_MS); // Wait for the UI to re-render after toggling return true; } /** * Identifies and returns the main scrollable element for AI Studio conversations. * Uses a cascade of selectors starting with the most specific, then falling back * to more general approaches, finally defaulting to document.documentElement. * @returns {Element} - The identified scrollable element. */ function getMainScrollerElement_AiStudio() { log("Searching for page scroll container..."); // Try the proven selectors from the extension let scroller = document.querySelector('ms-autoscroll-container'); if (scroller) { log("Found scroll container (ms-autoscroll-container)", 'success'); return scroller; } // Fallback to chat turns parent const chatTurnsContainer = document.querySelector('ms-chat-turn')?.parentElement; if (chatTurnsContainer) { let parent = chatTurnsContainer; for (let i = 0; i < SCROLL_PARENT_SEARCH_DEPTH && parent; i++) { if (parent.scrollHeight > parent.clientHeight + BOTTOM_DETECTION_TOLERANCE && (window.getComputedStyle(parent).overflowY === 'auto' || window.getComputedStyle(parent).overflowY === 'scroll')) { log("Found scroll container (searching parent elements)", 'success'); return parent; } parent = parent.parentElement; } } log("Warning: Using document.documentElement as fallback", 'warn'); return document.documentElement; } /** * Extracts data from all visible chat turns in the current AI Studio conversation. * Uses DOM traversal with CSP-compliant selectors to capture user prompts, * AI responses, and AI thinking output. Maintains a Map of collected data. * @returns {boolean} - True if new data was found or existing data was updated, false otherwise. */ async function extractDataIncremental_AiStudio(extractionMode = 'raw') { let newlyFoundCount = 0; let dataUpdatedInExistingTurn = false; const currentTurns = document.querySelectorAll('ms-chat-turn'); for (const [index, turn] of currentTurns.entries()) { const turnKey = turn; // Use turn element as key (consistent with MaxTab) const turnContainer = turn.querySelector('.chat-turn-container.user, .chat-turn-container.model'); if (!turnContainer) continue; // Skip only if no container found // ✅ Check if new turn, but DON'T skip existing ones let isNewTurn = !collectedData.has(turnKey); let extractedInfo = collectedData.get(turnKey) || { domOrder: index, type: 'unknown', userText: null, thoughtText: null, responseText: null }; if (isNewTurn) { collectedData.set(turnKey, extractedInfo); newlyFoundCount++; } let dataWasUpdatedThisTime = false; // Extract based on container type (exactly like MaxTab) if (turnContainer.classList.contains('user')) { if (extractedInfo.type === 'unknown') extractedInfo.type = 'user'; if (!extractedInfo.userText) { let userText = null; if (extractionMode === 'raw') { // RAW MODE: .very-large-text-container const rawContainer = turn.querySelector('ms-text-chunk .very-large-text-container'); if (rawContainer) { userText = rawContainer.textContent.trim(); } } else { // RENDERED MODE: ms-cmark-node let userNode = turn.querySelector('.turn-content ms-cmark-node'); if (userNode) { userText = userNode.innerText.trim(); } } if (userText) { extractedInfo.userText = userText; dataWasUpdatedThisTime = true; log(`Extracted user text from turn ${index}: "${userText.substring(0, 50)}..."`); } } } else if (turnContainer.classList.contains('model')) { if (extractedInfo.type === 'unknown') extractedInfo.type = 'model'; // ✅ NEW: Expand collapsed thinking sections BEFORE extraction await expandThinkingSections(turn, index); // Extract AI thinking output with multiple strategies if (!extractedInfo.thoughtText) { let thoughtText = null; if (extractionMode === 'raw') { // RAW MODE selector const rawThought = turn.querySelector('ms-thought-chunk .very-large-text-container'); if (rawThought) { thoughtText = rawThought.textContent.trim(); } } else { // RENDERED MODE selector let thoughtNode = turn.querySelector('ms-thought-chunk .mat-expansion-panel-body ms-cmark-node'); if (thoughtNode) { thoughtText = thoughtNode.textContent.trim(); } } if (thoughtText && thoughtText.length >= THOUGHT_MIN_LENGTH) { extractedInfo.thoughtText = thoughtText; dataWasUpdatedThisTime = true; log(`Extracted AI thinking from turn ${index}: "${thoughtText.substring(0, 50)}..."`); } } // Extract AI response (exactly like MaxTab) if (!extractedInfo.responseText) { const responseChunks = Array.from(turn.querySelectorAll('.turn-content > ms-prompt-chunk')); const responseTexts = responseChunks .filter(chunk => !chunk.querySelector('ms-thought-chunk')) .map(chunk => { if (extractionMode === 'raw') { // RAW MODE selector const rawContainer = chunk.querySelector('ms-text-chunk .very-large-text-container'); if (rawContainer) { return rawContainer.textContent.trim(); } } else { // RENDERED MODE selector const cmarkNode = chunk.querySelector('ms-cmark-node'); if (cmarkNode) { return cmarkNode.innerText.trim(); } } return chunk.innerText.trim(); // Fallback }) .filter(text => text); if (responseTexts.length > 0) { extractedInfo.responseText = responseTexts.join('\n\n'); dataWasUpdatedThisTime = true; log(`Extracted AI response from turn ${index} with ${responseTexts.length} chunks`); } else if (!extractedInfo.thoughtText) { // Fallback only if no thinking was found (like MaxTab) const turnContent = turn.querySelector('.turn-content'); if (turnContent) { extractedInfo.responseText = turnContent.innerText.trim(); dataWasUpdatedThisTime = true; log(`Extracted AI response from turn ${index} using fallback`); } } } // Set turn type (exactly like MaxTab) if (dataWasUpdatedThisTime) { if (extractedInfo.thoughtText && extractedInfo.responseText) extractedInfo.type = 'model_thought_reply'; else if (extractedInfo.responseText) extractedInfo.type = 'model_reply'; else if (extractedInfo.thoughtText) extractedInfo.type = 'model_thought'; } } // Update collected data if anything changed if (dataWasUpdatedThisTime) { collectedData.set(turnKey, extractedInfo); dataUpdatedInExistingTurn = true; } } if (currentTurns.length > 0 && collectedData.size === 0) { log("Warning: Chat turns exist but no data could be extracted. Please check selectors.", 'warn'); } else { log(`Scroll ${scrollCount}/${MAX_SCROLL_ATTEMPTS}... Found ${collectedData.size} total records`); } return newlyFoundCount > 0 || dataUpdatedInExistingTurn; } /** * Performs high-speed auto-scrolling through AI Studio conversations to capture all content. * Uses incremental scrolling with hardcoded delays for maximum speed, detecting end conditions * and handling problematic scroll jumps. Performs final collection passes for completeness. * @async * @param {string} direction - The direction to scroll ('down' or 'up'). * @returns {Promise<boolean>} - True if the scrolling completed successfully, false otherwise. */ async function autoScroll_AiStudio(direction = 'down') { log(`Starting auto-scroll (direction: ${direction})...`); isScrolling = true; collectedData.clear(); scrollCount = 0; noChangeCounter = 0; scrollIncrement = SCROLL_INCREMENT_INITIAL; // Reset to initial value const scroller = getMainScrollerElement_AiStudio(); if (!scroller) { log('Error: Cannot find scroll area!', 'error'); alert('Unable to find chat scroll area. Auto-scroll cannot proceed.'); isScrolling = false; return false; } log(`Using scroll element: ${scroller.tagName}.${scroller.className.split(' ').join('.')}`); const isWindowScroller = (scroller === document.documentElement || scroller === document.body); const getScrollTop = () => isWindowScroller ? window.scrollY : scroller.scrollTop; const getScrollHeight = () => isWindowScroller ? document.documentElement.scrollHeight : scroller.scrollHeight; const getClientHeight = () => isWindowScroller ? window.innerHeight : scroller.clientHeight; if (direction === 'up') { if (isWindowScroller) { window.scrollTo({ top: getScrollHeight(), behavior: 'instant' }); } else { scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'instant' }); } await delay(UPWARD_SCROLL_DELAY_MS); } // Start from top and ensure content is loaded (extension pattern) scroller.scrollTop = 0; await delay(INITIAL_SCROLL_DELAY); scroller.scrollTop = scroller.scrollHeight; await delay(INITIAL_SCROLL_DELAY); scroller.scrollTop = 0; await delay(INITIAL_SCROLL_DELAY); log(`Starting incremental scroll (up to ${MAX_SCROLL_ATTEMPTS} attempts)...`); let lastScrollTop = -1; let reachedEnd = false; let scrollMessages = ["scrolling...", "scrolling..", "scrolling.", "scrolling"]; // Initial collection const desiredMode = PREFER_RAW_MODE ? 'raw' : 'rendered'; const initialNewCount = await extractDataIncremental_AiStudio(desiredMode); log(`Initial collection: ${collectedData.size} messages`); while (scrollCount < MAX_SCROLL_ATTEMPTS && !reachedEnd && isScrolling) { if (abortController.signal.aborted) { log('Scroll aborted by user.', 'warn'); isScrolling = false; break; } const currentTop = scroller.scrollTop; const clientHeight = scroller.clientHeight; const scrollHeight = scroller.scrollHeight; // End detection: Check if we've reached the bottom of the conversation // Allow tolerance for potential rounding issues if (currentTop + clientHeight >= scrollHeight - BOTTOM_DETECTION_TOLERANCE) { log("Reached bottom of conversation - no more content to scroll", 'success'); reachedEnd = true; break; } // Calculate the next scroll target position // Add the increment to the current position, but don't exceed the maximum possible scroll let intendedScrollTarget = currentTop + scrollIncrement; const maxPossibleScrollTop = scrollHeight - clientHeight; if (intendedScrollTarget > maxPossibleScrollTop) { intendedScrollTarget = maxPossibleScrollTop; // Clamp to bottom } // Execute the scroll operation scroller.scrollTop = intendedScrollTarget; scrollCount++; // Machine-gun speed delay: 50ms for maximum performance // May miss content on very slow connections await delay(SCROLL_DELAY_MS); const effectiveScrollTop = scroller.scrollTop; const actualScrolledDistance = effectiveScrollTop - currentTop; // Detect problematic scroll jumps that might indicate UI issues // A jump > configured factor of intended distance suggests the UI may have re-arranged let isProblematicJump = false; if (actualScrolledDistance > PROBLEMATIC_JUMP_FACTOR * scrollIncrement && intendedScrollTarget < maxPossibleScrollTop - BOTTOM_DETECTION_TOLERANCE) { isProblematicJump = true; log(`Scroll jump detected. From: ${currentTop}, Aimed: ${intendedScrollTarget}, Got: ${effectiveScrollTop}`, 'warn'); } // Detect when scrolling has effectively stopped (conversation end) // If we moved less than the minimum threshold and this isn't the first scroll, assume we're at the end if (actualScrolledDistance < MIN_SCROLL_DISTANCE_THRESHOLD && scrollCount > 1) { log("Scroll effectively stopped, assuming end of conversation", 'success'); reachedEnd = true; break; } // Extract any newly visible messages after this scroll position const newlyAddedCount = await extractDataIncremental_AiStudio(desiredMode); // Update progress indicator with cycling messages for better UX const loadingMessage = scrollMessages[(scrollCount - 1) % scrollMessages.length]; const indicatorMessage = `${loadingMessage} (Found ${collectedData.size} messages)`; log(indicatorMessage); // Update tracking variables for next iteration lastScrollTop = effectiveScrollTop; } // Handle different completion scenarios with detailed logging if (!isScrolling && scrollCount < MAX_SCROLL_ATTEMPTS && !abortController.signal.aborted) { log(`Scroll manually stopped by user (total ${scrollCount} attempts).`, 'warn'); } else if (scrollCount >= MAX_SCROLL_ATTEMPTS) { log(`Reached maximum scroll attempts limit (${MAX_SCROLL_ATTEMPTS}). Some content may be missing.`, 'warn'); } else if (reachedEnd) { log(`Scroll completed successfully after ${scrollCount} attempts. All conversation content captured.`, 'success'); } // Final collection passes to ensure no content was missed // These additional passes capture any content that might have been loaded during scrolling log("Performing final collection passes to ensure completeness..."); // Pass 1: Top of conversation scroller.scrollTop = 0; await delay(FINAL_COLLECTION_DELAY_MS); await extractDataIncremental_AiStudio(desiredMode); // Pass 2: Middle of conversation scroller.scrollTop = scroller.scrollHeight / 2; await delay(FINAL_COLLECTION_DELAY_MS); await extractDataIncremental_AiStudio(desiredMode); // Pass 3: Bottom of conversation scroller.scrollTop = scroller.scrollHeight; await delay(FINAL_COLLECTION_DELAY_MS); await extractDataIncremental_AiStudio(desiredMode); log(`Final data collection complete. Total records: ${collectedData.size}`, 'success'); isScrolling = false; return true; } /** * Formats collected conversation data and triggers the download of a Markdown file. * Sorts data by DOM order, formats user prompts and AI responses with proper Markdown * structure, and generates a downloadable .md file with timestamp. * @returns {boolean} - True if the file was successfully created and downloaded, false otherwise. */ function formatAndTriggerDownload() { log(`Processing ${collectedData.size} records and generating file...`); const finalTurnsInDom = document.querySelectorAll('ms-chat-turn'); let sortedData = []; // Sort by DOM order - now using the turn elements as keys finalTurnsInDom.forEach(turnNode => { if (collectedData.has(turnNode)) { sortedData.push(collectedData.get(turnNode)); } }); log(`Final export: ${sortedData.length} records found for export`); if (sortedData.length === 0) { log('No valid records found for export!', 'error'); alert('After scrolling, no chat records were found for export. Please check the console for details.'); return false; } let fileContent = 'Google AI Studio Chat Records (Auto-Scroll Capture)\n'; fileContent += '=========================================\n\n'; fileContent += `Exported: ${new Date().toLocaleString()}\n`; fileContent += `Total Records: ${sortedData.length}\n\n`; sortedData.forEach(item => { let parts = []; if (INCLUDE_USER_MESSAGES && item.type === 'user' && item.userText) { parts.push(`--- User ---\n${item.userText}`); } // Include AI thinking output if (INCLUDE_AI_THINKING && (item.type === 'model_thought' || item.type === 'model_thought_reply')) { if (item.thoughtText) { parts.push(`--- AI Thinking ---\n${item.thoughtText}`); } } // Include AI response if (INCLUDE_AI_RESPONSES && (item.type === 'model_reply' || item.type === 'model_thought_reply')) { if (item.responseText) { parts.push(`--- AI Response ---\n${item.responseText}`); } } if (parts.length > 0) { const turnContent = parts.join('\n\n'); fileContent += turnContent + '\n\n------------------------------\n\n'; } }); // Add warning if no content was exported due to filters if (sortedData.length > 0 && !fileContent.includes('---')) { log('Warning: All content was filtered out based on configuration settings', 'warn'); alert('No content to export based on current filter settings. Please check INCLUDE_* configuration options.'); return false; } // Clean up trailing separator fileContent = fileContent.replace(/\n\n------------------------------\n\n$/, '\n').trim(); try { const blob = new Blob([fileContent], { type: 'text/markdown;charset=utf-8' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); link.href = url; link.download = `${EXPORT_FILENAME_PREFIX}${getCurrentTimestamp()}.md`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); log("File successfully generated and download triggered.", 'success'); return true; } catch (e) { log(`File creation failed: ${e.message}`, 'error'); alert("Error creating download file: " + e.message); return false; } } /** * Aborts the currently running extraction process. */ function abortExtraction() { if (abortController) { abortController.abort(); } isScrolling = false; log('Extraction manually aborted by user.', 'warn'); setButtonState('IDLE'); } /** * Main orchestrator function for the export process. * Handles the complete workflow including Raw Mode toggle, scrolling, data extraction, * and file download. Ensures proper error handling and UI state management. * @async */ async function handleScrollExtraction() { abortController = new AbortController(); setButtonState('WORKING'); const currentMode = detectCurrentMode(); const desiredMode = PREFER_RAW_MODE ? 'raw' : 'rendered'; let needsToggle = (currentMode !== desiredMode); let modeWasToggled = false; try { if (needsToggle) { log(`Switching from ${currentMode} to ${desiredMode} mode...`); modeWasToggled = await toggleRawMode(); if (!modeWasToggled) { throw new Error(`Failed to switch to ${desiredMode} Mode.`); } } else { log(`Already in desired ${desiredMode} mode, no toggle needed.`); } if (abortController.signal.aborted) throw new Error("Aborted after mode switch."); const scrollSuccess = await autoScroll_AiStudio('down'); if (scrollSuccess === false && !abortController.signal.aborted) { throw new Error("Scrolling process failed or was incomplete."); } if (abortController.signal.aborted) throw new Error("Aborted during scroll."); log('Scroll completed, preparing final data processing...'); await delay(FINAL_CAPTURE_DELAY_MS); await extractDataIncremental_AiStudio(desiredMode); await delay(POST_EXTRACTION_DELAY_MS); const downloadSuccess = formatAndTriggerDownload(); if (!downloadSuccess) { throw new Error("File generation or download failed."); } setButtonState('SUCCESS'); } catch (error) { if (!abortController.signal.aborted) { log(`Error during processing: ${error.message}`, 'error'); // alert(`An error occurred: ${error.message}`); setButtonState('ERROR'); } else { log('Process was aborted, cleaning up.', 'warn'); } } finally { if (modeWasToggled) { log(`Switching back to ${currentMode} mode...`); await toggleRawMode(); } isScrolling = false; } } //================================================================================ // UI CREATION - User interface components and interactions //================================================================================ /** * Sets the visual state of the export button. * @param {'IDLE'|'WORKING'|'SUCCESS'|'ERROR'} state The target state. */ function setButtonState(state) { exportButtonState = state; if (!exportButton || !exportIcon) return; // Remove existing color classes exportButton.classList.remove('success', 'error'); switch (state) { case 'IDLE': exportIcon.textContent = 'download'; exportButton.title = 'Export Chat to Markdown'; exportButton.disabled = false; break; case 'WORKING': exportIcon.textContent = 'cancel'; exportIcon.style.color = '#d93025'; // Red color for stop exportButton.title = 'Stop Export'; exportButton.disabled = false; break; case 'SUCCESS': exportIcon.textContent = 'check_circle'; exportIcon.style.color = '#1e8e3e'; // Green color for success exportButton.title = 'Export Successful!'; exportButton.disabled = true; setTimeout(() => setButtonState('IDLE'), SUCCESS_RESET_TIMEOUT_MS); break; case 'ERROR': exportIcon.textContent = 'error'; exportIcon.style.color = '#d93025'; // Red color for error exportButton.title = 'Export Failed!'; exportButton.disabled = true; setTimeout(() => setButtonState('IDLE'), ERROR_RESET_TIMEOUT_MS); break; } // Reset color for non-transient states if (state === 'IDLE') { exportIcon.style.color = ''; // Use default color } } /** * Creates and initializes the export button UI element with Google-styled appearance. * Positions the button fixed at the bottom-left of the page and attaches the export handler. */ function createUI() { const toolbarRight = document.querySelector('ms-toolbar .toolbar-right'); if (!toolbarRight || document.getElementById('export-button-container')) { return; } log('Toolbar found, creating export button...'); // Create container for the button group const buttonContainer = document.createElement('div'); buttonContainer.id = 'export-button-container'; buttonContainer.style.cssText = 'display: flex; align-items: center; margin: 0 4px; position: relative; z-index: 2147483647;'; // Create main export button exportButton = document.createElement('button'); exportButton.id = 'aistudio-export-button'; exportButton.setAttribute('ms-button', ''); exportButton.setAttribute('variant', 'icon-borderless'); exportButton.className = 'mat-mdc-tooltip-trigger ms-button-borderless ms-button-icon'; exportButton.addEventListener('click', () => { if (exportButtonState === 'IDLE') { handleScrollExtraction(); } else if (exportButtonState === 'WORKING') { abortExtraction(); } }); exportIcon = document.createElement('span'); exportIcon.className = 'material-symbols-outlined notranslate ms-button-icon-symbol'; exportButton.appendChild(exportIcon); // Create settings button const settingsButton = document.createElement('button'); settingsButton.id = 'aistudio-settings-button'; settingsButton.title = 'Export Settings'; settingsButton.setAttribute('ms-button', ''); settingsButton.setAttribute('variant', 'icon-borderless'); settingsButton.className = 'mat-mdc-tooltip-trigger ms-button-borderless ms-button-icon'; const settingsIcon = document.createElement('span'); settingsIcon.className = 'material-symbols-outlined notranslate ms-button-icon-symbol'; settingsIcon.textContent = 'settings'; settingsButton.appendChild(settingsIcon); // Append buttons to container buttonContainer.appendChild(exportButton); buttonContainer.appendChild(settingsButton); // Create settings panel with ALTERNATIVE STRATEGY 1: position: fixed // This moves the panel out of the toolbar hierarchy entirely settingsPanel = document.createElement('div'); settingsPanel.id = 'aistudio-settings-panel'; settingsPanel.style.cssText = ` position: fixed; top: 52px; right: 16px; width: 240px; background: #2d2e30; border: 1px solid #5f6368; border-radius: 8px; z-index: 2147483647; font-family: 'Google Sans', sans-serif; font-size: 14px; display: none; flex-direction: column; box-sizing: border-box; padding-top: 16px; padding-right: 16px; padding-bottom: 16px; padding-left: 16px; box-shadow: 0 8px 16px rgba(0,0,0,0.3); color: #e8eaed; isolation: isolate; transform: translateZ(0); `; function createCheckbox(label, configVar, callback) { const wrapper = document.createElement('div'); wrapper.style.cssText = 'display: flex; align-items: center; margin-bottom: 10px;'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = configVar; // Generate a unique ID that won't conflict between chat sessions const timestamp = Date.now(); const randomId = Math.floor(Math.random() * 10000); checkbox.id = `export-checkbox-${label.replace(/\s+/g, '-')}-${timestamp}-${randomId}`; const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.style.marginLeft = '10px'; labelEl.style.cursor = 'pointer'; // Visual hint that it's clickable labelEl.setAttribute('for', checkbox.id); // Store a reference to the callback for both label and checkbox const handleChange = (checked) => { checkbox.checked = checked; callback(checked); }; // Make the label directly toggle the checkbox labelEl.addEventListener('click', (event) => { event.preventDefault(); // Prevent default behavior handleChange(!checkbox.checked); }); // Also ensure the checkbox itself works checkbox.addEventListener('change', (event) => callback(event.target.checked)); wrapper.appendChild(checkbox); wrapper.appendChild(labelEl); settingsPanel.appendChild(wrapper); return checkbox; } preferRawModeCheckbox = createCheckbox('Prefer Raw Mode', PREFER_RAW_MODE, (val) => PREFER_RAW_MODE = val); includeUserMessagesCheckbox = createCheckbox('Include User Messages', INCLUDE_USER_MESSAGES, (val) => INCLUDE_USER_MESSAGES = val); includeAiResponsesCheckbox = createCheckbox('Include AI Responses', INCLUDE_AI_RESPONSES, (val) => INCLUDE_AI_RESPONSES = val); includeAiThinkingCheckbox = createCheckbox('Include AI Thinking', INCLUDE_AI_THINKING, (val) => INCLUDE_AI_THINKING = val); // Remove margin-bottom from the last checkbox to prevent extra space at the bottom const lastWrapper = settingsPanel.lastElementChild; if (lastWrapper) { lastWrapper.style.marginBottom = '0'; } // ALTERNATIVE STRATEGY 1: Append panel to body instead of buttonContainer // This completely removes it from the toolbar stacking context document.body.appendChild(settingsPanel); // Update position dynamically based on button location const updatePanelPosition = () => { const buttonRect = settingsButton.getBoundingClientRect(); settingsPanel.style.top = `${buttonRect.bottom + 4}px`; settingsPanel.style.right = `${window.innerWidth - buttonRect.right}px`; }; // Settings button logic settingsButton.addEventListener('click', (event) => { event.stopPropagation(); const isHidden = settingsPanel.style.display === 'none' || settingsPanel.style.display === ''; if (isHidden) { updatePanelPosition(); // Update position before showing } settingsPanel.style.display = isHidden ? 'flex' : 'none'; }); // Update position on scroll/resize window.addEventListener('scroll', () => { if (settingsPanel.style.display === 'flex') { updatePanelPosition(); } }); window.addEventListener('resize', () => { if (settingsPanel.style.display === 'flex') { updatePanelPosition(); } }); // Close panel on outside click document.addEventListener('click', (event) => { if (!settingsPanel.contains(event.target) && !settingsButton.contains(event.target)) { settingsPanel.style.display = 'none'; } }); // Inject into toolbar const moreButton = toolbarRight.querySelector('button[iconname="more_vert"]'); if (moreButton) { toolbarRight.insertBefore(buttonContainer, moreButton); } else { toolbarRight.appendChild(buttonContainer); } log("Toolbar UI initialization complete.", 'success'); setButtonState('IDLE'); // Set the initial state of the button } //================================================================================ // MAIN EXECUTION - Script initialization and startup //================================================================================ /** * Main entry point function that initializes the AI Studio Export script. * Creates the UI and sets up the script for user interaction. */ function initialize() { log("Initializing AI Studio Export..."); // Initial attempt to create the UI in case the toolbar is already present. createUI(); // Set up an observer to re-create the UI if the toolbar is ever re-rendered const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (node.nodeType === 1 && (node.tagName === 'MS-TOOLBAR' || node.querySelector('ms-toolbar'))) { createUI(); return; // We found the toolbar, no need to keep searching } } } } }); observer.observe(document.body, { childList: true, subtree: true }); log("MutationObserver is now watching for toolbar changes."); } initialize(); })();