// ==UserScript==
// @name Gemini 聊天对话增强脚本
// @namespace http://tampermonkey.net/
// @version 1.2.0
// @description 一键导出 Google Gemini 的网页端对话聊天记录为 JSON / TXT / Markdown 文件,支持对话内目录导航。
// @author sxuan
// @match https://gemini.google.com/app*
// @match https://gemini.google.com/u/*/app*
// @grant GM_addStyle
// @grant GM_setClipboard
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCACAyNCIgZmlsbD0iIzAwNzhmZiI+PHBhdGggZD0iTTE5LjUgMi4yNWgtMTVjLTEuMjQgMC0yLjI1IDEuMDEtMi4yNSAyLjI1djE1YzAgMS4yNCAxLjAxIDIuMjUgMi4yNSAyLjI1aDE1YzEuMjQgMCAyLjI1LTEuMDEgMi4yNS0yLjI1di0xNWMwLTEuMjQtMS4wMS0yLjI1LTIuMjUtMi4yNXptLTIuMjUgNmgtMTAuNWMtLjQxIDAtLjc1LS4zNC0uNzUtLjc1cy4zNC0uNzUuNzUtLjc1aDEwLjVjLjQxIDAgLjc1LjM0Ljc1Ljc1cy0uMzQuNzUtLjc1Ljc1em0wIDRoLTEwLjVjLS40MSAwLS43NS0uMzQtLjc1LS43NXMuMzQtLjc1Ljc1LS43NWgxMC41Yy40MSAwIC43NS4zNC43NS43NXMtLjM0Ljc1LS4yNS43NXptLTMgNGgtNy41Yy0uNDEgMC0uNzUtLjM0LS43NS0uNzVzLjM0LS43NS43NS0uNzVoNy41Yy40MSAwIC43NS4zNC43NS43NXMtLjM0Ljc1LS43NS43NXoiLz48L3N2Zz4=
// @updateURL https://raw.githubusercontent.com/Sxuan-Coder/gemini_chat_export/main/gemini_chat_export.user.js
// @downloadURL https://raw.githubusercontent.com/Sxuan-Coder/gemini_chat_export/main/gemini_chat_export.user.js
// @license Apache-2.0
// ==/UserScript==
(function () {
'use strict';
// TrustedTypes 策略引用
let trustedHTMLPolicy = null;
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
if (!window.trustedTypes.defaultPolicy) {
trustedHTMLPolicy = window.trustedTypes.createPolicy('default', {
createHTML: (string) => string,
createScript: (string) => string,
createScriptURL: (string) => string
});
} else {
trustedHTMLPolicy = window.trustedTypes.defaultPolicy;
}
} catch (e) {
try {
trustedHTMLPolicy = window.trustedTypes.createPolicy('gemini-export-policy', {
createHTML: (string) => string,
createScript: (string) => string,
createScriptURL: (string) => string
});
} catch (e2) {
console.warn('TrustedTypes 策略创建失败,使用 DOM API 替代', e2);
}
}
}
const safeSetInnerHTML = (element, html) => {
if (!element) return;
if (trustedHTMLPolicy) {
try {
element.innerHTML = trustedHTMLPolicy.createHTML(html);
return;
} catch (e) { }
}
if (window.trustedTypes && window.trustedTypes.defaultPolicy) {
try {
element.innerHTML = window.trustedTypes.defaultPolicy.createHTML(html);
return;
} catch (e) { }
}
if (!window.trustedTypes) {
element.innerHTML = html;
return;
}
try {
const template = document.createElement('template');
if (element.setHTML) {
element.setHTML(html);
return;
}
while (element.firstChild) {
element.removeChild(element.firstChild);
}
const range = document.createRange();
range.selectNode(document.body);
const fragment = range.createContextualFragment(html);
element.appendChild(fragment);
} catch (e) {
console.warn('safeSetInnerHTML 回退到纯文本', e);
element.textContent = html.replace(/<[^>]*>/g, '');
}
};
// --- 全局配置常量 ---
window.__GEMINI_EXPORT_FORMAT = window.__GEMINI_EXPORT_FORMAT || 'txt';
const buttonTextStartScroll = "滚动导出对话";
const buttonTextStopScroll = "停止滚动";
const buttonTextProcessingScroll = "处理滚动数据...";
const successTextScroll = "滚动导出对话成功!";
const errorTextScroll = "滚动导出失败";
const buttonTextCanvasExport = "导出Canvas";
const buttonTextCanvasProcessing = "处理Canvas数据...";
const successTextCanvas = "Canvas 导出成功!";
const errorTextCanvas = "Canvas 导出失败";
const buttonTextCombinedExport = "一键导出对话+Canvas";
const buttonTextCombinedProcessing = "处理组合数据...";
const successTextCombined = "组合导出成功!";
const errorTextCombined = "组合导出失败";
const exportTimeout = 3000;
const SCROLL_DELAY_MS = 1000;
const MAX_SCROLL_ATTEMPTS = 300;
const SCROLL_INCREMENT_FACTOR = 0.85;
const SCROLL_STABILITY_CHECKS = 3;
if (!window.__GEMINI_EXPORT_FORMAT) { window.__GEMINI_EXPORT_FORMAT = 'txt'; }
// --- 脚本内部状态变量 ---
let isScrolling = false;
let collectedData = new Map();
let scrollCount = 0;
let noChangeCounter = 0;
let captureButtonScroll = null;
let stopButtonScroll = null;
let captureButtonCanvas = null;
let captureButtonCombined = null;
let statusDiv = null;
let hideButton = null;
let buttonContainer = null;
let sidePanel = null;
let toggleButton = null;
let formatSelector = null;
let conversationDirectoryPanel = null;
let conversationDirectoryContainer = null;
let conversationDirectoryObserver = null;
let conversationDirectoryUpdateTimer = null;
let conversationDirectoryAnchorSeq = 0;
let conversationDirectoryLastSignature = '';
let directoryCollapsed = false;
let directoryDragState = { isDragging: false, startX: 0, startY: 0, startTop: 0, startRight: 0 };
const DIRECTORY_POS_KEY = 'gemini_export_directory_position';
const DIRECTORY_COLLAPSED_KEY = 'gemini_export_directory_collapsed';
let themeObserver = null;
let themeUpdateTimer = null;
let currentThemeMode = null;
let toastContainer = null;
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function parseRgbColor(colorString) {
if (!colorString) return null;
const m = colorString.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (!m) return null;
return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
}
function getPageBackgroundColor() {
try {
const bodyBg = window.getComputedStyle(document.body).backgroundColor;
if (bodyBg && bodyBg !== 'rgba(0, 0, 0, 0)' && bodyBg !== 'transparent') return bodyBg;
} catch (_) { }
try {
return window.getComputedStyle(document.documentElement).backgroundColor;
} catch (_) { }
return '';
}
function detectPageThemeMode() {
try {
const scheme = window.getComputedStyle(document.documentElement).colorScheme;
if (scheme && scheme.includes('dark')) return 'dark';
if (scheme && scheme.includes('light')) return 'light';
} catch (_) { }
const rgb = parseRgbColor(getPageBackgroundColor());
if (rgb) {
const luminance = (0.2126 * rgb.r) + (0.7152 * rgb.g) + (0.0722 * rgb.b);
return luminance < 128 ? 'dark' : 'light';
}
try {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} catch (_) { }
return 'dark';
}
function applyThemeVariables(mode) {
const darkVars = {
'--ge-panel-bg': 'rgba(30, 30, 45, 0.85)',
'--ge-panel-text': '#F9FAFB',
'--ge-text-muted': '#D1D5DB',
'--ge-text-muted-2': '#9CA3AF',
'--ge-border': 'rgba(255, 255, 255, 0.15)',
'--ge-border-hover': 'rgba(255, 255, 255, 0.35)',
'--ge-surface': 'rgba(255, 255, 255, 0.08)',
'--ge-surface-2': 'rgba(30, 30, 45, 0.95)',
'--ge-surface-hover': 'rgba(255, 255, 255, 0.12)',
'--ge-divider': 'rgba(255, 255, 255, 0.08)',
'--ge-primary': '#3b82f6',
'--ge-primary-hover': '#60a5fa',
'--ge-primary-border': '#3b82f6',
'--ge-on-primary': '#FFFFFF',
'--ge-success': '#10b981',
'--ge-success-border': '#10b981',
'--ge-danger': '#ef4444',
'--ge-danger-border': '#ef4444',
'--ge-neutral': '#64748b',
'--ge-neutral-border': '#64748b',
'--ge-scroll-thumb': 'rgba(255, 255, 255, 0.2)',
'--ge-scroll-thumb-hover': 'rgba(255, 255, 255, 0.35)',
'--ge-accent': '#f59e0b',
'--ge-gradient-primary': 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
'--ge-gradient-success': 'linear-gradient(135deg, #11998e 0%, #10b981 100%)',
'--ge-gradient-danger': 'linear-gradient(135deg, #ed213a 0%, #ef4444 100%)',
'--ge-glass-blur': 'blur(20px) saturate(180%)',
'--ge-glass-shadow': '0 12px 48px rgba(31, 38, 135, 0.25)',
'--ge-font-family': "'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
};
const lightVars = {
'--ge-panel-bg': 'rgba(255, 255, 255, 0.75)',
'--ge-panel-text': '#1F2937',
'--ge-text-muted': '#4B5563',
'--ge-text-muted-2': '#6B7280',
'--ge-border': 'rgba(255, 255, 255, 0.9)',
'--ge-border-hover': 'rgba(255, 255, 255, 0.6)',
'--ge-surface': 'rgba(255, 255, 255, 0.5)',
'--ge-surface-2': 'rgba(255, 255, 255, 0.9)',
'--ge-surface-hover': 'rgba(255, 255, 255, 0.65)',
'--ge-divider': 'rgba(255, 255, 255, 0.7)',
'--ge-primary': '#3b82f6',
'--ge-primary-hover': '#2563eb',
'--ge-primary-border': '#3b82f6',
'--ge-on-primary': '#FFFFFF',
'--ge-success': '#10b981',
'--ge-success-border': '#10b981',
'--ge-danger': '#ef4444',
'--ge-danger-border': '#ef4444',
'--ge-neutral': '#64748b',
'--ge-neutral-border': '#64748b',
'--ge-scroll-thumb': 'rgba(0, 0, 0, 0.15)',
'--ge-scroll-thumb-hover': 'rgba(0, 0, 0, 0.3)',
'--ge-accent': '#f59e0b',
'--ge-gradient-primary': 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
'--ge-gradient-success': 'linear-gradient(135deg, #11998e 0%, #10b981 100%)',
'--ge-gradient-danger': 'linear-gradient(135deg, #ed213a 0%, #ef4444 100%)',
'--ge-glass-blur': 'blur(20px) saturate(180%)',
'--ge-glass-shadow': '0 12px 48px rgba(31, 38, 135, 0.15)',
'--ge-font-family': "'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
};
const vars = mode === 'light' ? lightVars : darkVars;
Object.entries(vars).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
});
currentThemeMode = mode;
}
function refreshThemeIfNeeded() {
const nextMode = detectPageThemeMode();
if (nextMode === currentThemeMode) return;
applyThemeVariables(nextMode);
}
function scheduleThemeRefresh(delayMs = 120) {
if (themeUpdateTimer) window.clearTimeout(themeUpdateTimer);
themeUpdateTimer = window.setTimeout(() => {
themeUpdateTimer = null;
refreshThemeIfNeeded();
}, delayMs);
}
function startThemeSync() {
applyThemeVariables(detectPageThemeMode());
if (themeObserver) themeObserver.disconnect();
themeObserver = new MutationObserver(() => scheduleThemeRefresh(120));
try {
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'data-theme', 'data-color-scheme', 'color-scheme']
});
} catch (_) { }
try {
themeObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'style']
});
} catch (_) { }
try {
const media = window.matchMedia('(prefers-color-scheme: dark)');
if (media && media.addEventListener) media.addEventListener('change', () => scheduleThemeRefresh(120));
else if (media && media.addListener) media.addListener(() => scheduleThemeRefresh(120));
} catch (_) { }
}
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}`;
}
function getProjectName() {
try {
const firstUser = document.querySelector('#chat-history user-query .query-text, #chat-history user-query .query-text-line, #chat-history user-query .query-text p');
if (firstUser && firstUser.textContent && firstUser.textContent.trim()) {
const raw = firstUser.textContent.trim().replace(/\s+/g, ' ');
const clean = raw.substring(0, 20).replace(/[\\/:\*\?"<>\|]/g, '_');
if (clean) return `Gemini_${clean}`;
}
} catch (e) { console.warn('Gemini 项目名提取失败,回退 XPath', e); }
const xpath = "/html/body/app-root/ms-app/div/div/div/div/span/ms-prompt-switcher/ms-chunk-editor/section/ms-toolbar/div/div[1]/div/div/h1";
const defaultName = "GeminiChat";
try {
const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
const titleElement = result.singleNodeValue;
if (titleElement && titleElement.textContent) {
const cleanName = titleElement.textContent.trim().replace(/[\\/:\*\?"<>\|]/g, '_');
return cleanName || defaultName;
}
} catch (e) { }
return defaultName;
}
function getMainScrollerElement_AiStudio() {
console.log("尝试查找滚动容器 (用于滚动导出)...");
let scroller = document.querySelector('.chat-scrollable-container');
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
console.log("找到滚动容器 (策略 1: .chat-scrollable-container):", scroller);
return scroller;
}
scroller = document.querySelector('mat-sidenav-content');
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
console.log("找到滚动容器 (策略 2: mat-sidenav-content):", scroller);
return scroller;
}
const chatTurnsContainer = document.querySelector('ms-chat-turn')?.parentElement;
if (chatTurnsContainer) {
let parent = chatTurnsContainer;
for (let i = 0; i < 5 && parent; i++) {
if (parent.scrollHeight > parent.clientHeight + 10 &&
(window.getComputedStyle(parent).overflowY === 'auto' || window.getComputedStyle(parent).overflowY === 'scroll')) {
console.log("找到滚动容器 (策略 3: 向上查找父元素):", parent);
return parent;
}
parent = parent.parentElement;
}
}
console.warn("警告 (滚动导出): 未能通过特定选择器精确找到 AI Studio 滚动区域,将尝试使用 document.documentElement。如果滚动不工作,请按F12检查聊天区域的HTML结构,并更新此函数内的选择器。");
return document.documentElement;
}
// Gemini 新增滚动容器获取与解析逻辑
function getMainScrollerElement_Gemini() {
return document.querySelector('#chat-history') || document.documentElement;
}
function extractDataIncremental_Gemini() {
let newly = 0, updated = false;
const nodes = document.querySelectorAll('#chat-history .conversation-container');
const seenUserTexts = new Set(); // 用于去重用户消息
nodes.forEach((c, idx) => {
let info = collectedData.get(c) || { domOrder: idx, type: 'unknown', userText: null, thoughtText: null, responseText: null };
let changed = false;
if (!collectedData.has(c)) { collectedData.set(c, info); newly++; }
if (!info.userText) {
const userTexts = Array.from(c.querySelectorAll('user-query .query-text-line, user-query .query-text p, user-query .query-text'))
.map(el => el.innerText.trim()).filter(Boolean);
if (userTexts.length) {
const combinedUserText = userTexts.join('\n');
// 检查是否已经存在相同的用户消息
if (!seenUserTexts.has(combinedUserText)) {
seenUserTexts.add(combinedUserText);
info.userText = combinedUserText;
changed = true;
if (info.type === 'unknown') info.type = 'user';
}
}
}
const modelRoot = c.querySelector('.response-container-content, model-response');
if (modelRoot) {
if (!info.responseText) {
const md = modelRoot.querySelector('.model-response-text .markdown');
if (md && md.innerText.trim()) { info.responseText = md.innerText.trim(); changed = true; }
}
if (!info.thoughtText) {
const thoughts = modelRoot.querySelector('model-thoughts');
if (thoughts) {
let textReal = '';
const body = thoughts.querySelector('.thoughts-body, .thoughts-content');
if (body && body.innerText.trim() && !/显示思路/.test(body.innerText.trim())) textReal = body.innerText.trim();
info.thoughtText = textReal || '(思维链未展开)'; // 占位策略 A
changed = true;
}
}
}
if (changed) {
if (info.userText && info.responseText && info.thoughtText) info.type = 'model_thought_reply';
else if (info.userText && info.responseText) info.type = 'model_reply';
else if (info.userText) info.type = 'user';
else if (info.responseText && info.thoughtText) info.type = 'model_thought_reply';
else if (info.responseText) info.type = 'model_reply';
else if (info.thoughtText) info.type = 'model_thought';
collectedData.set(c, info); updated = true;
}
});
updateStatus(`滚动 ${scrollCount}/${MAX_SCROLL_ATTEMPTS}... 已收集 ${collectedData.size} 条记录..`);
scheduleConversationDirectoryUpdate(0);
return newly > 0 || updated;
}
function extractDataIncremental_Dispatch() {
if (document.querySelector('#chat-history .conversation-container')) return extractDataIncremental_Gemini();
return extractDataIncremental_AiStudio();
}
function scheduleConversationDirectoryUpdate(delayMs = 200) {
if (!conversationDirectoryContainer) return;
if (conversationDirectoryUpdateTimer) window.clearTimeout(conversationDirectoryUpdateTimer);
conversationDirectoryUpdateTimer = window.setTimeout(() => {
conversationDirectoryUpdateTimer = null;
updateConversationDirectory();
}, delayMs);
}
function ensureConversationAnchor(element) {
if (!element) return null;
const existing = element.dataset.geminiExportAnchorId;
if (existing) return existing;
if (element.id) {
element.dataset.geminiExportAnchorId = element.id;
return element.id;
}
conversationDirectoryAnchorSeq += 1;
const id = `gemini-export-anchor-${conversationDirectoryAnchorSeq}`;
element.id = id;
element.dataset.geminiExportAnchorId = id;
return id;
}
function collectUserPromptsForDirectory() {
const results = [];
const geminiContainers = document.querySelectorAll('#chat-history .conversation-container');
if (geminiContainers && geminiContainers.length) {
const seenTexts = new Set();
geminiContainers.forEach((c) => {
// 优先尝试获取最具体的文本元素,避免重复
let userText = '';
const queryTextLine = c.querySelector('user-query .query-text-line');
const queryTextP = c.querySelector('user-query .query-text p');
const queryText = c.querySelector('user-query .query-text');
if (queryTextLine) {
userText = (queryTextLine.innerText || '').trim();
} else if (queryTextP) {
userText = (queryTextP.innerText || '').trim();
} else if (queryText) {
userText = (queryText.innerText || '').trim();
}
if (!userText) return;
// 去重:避免相同文本多次出现
if (seenTexts.has(userText)) return;
seenTexts.add(userText);
const anchorId = ensureConversationAnchor(c);
if (!anchorId) return;
results.push({ anchorId, text: userText });
});
return results;
}
const turns = document.querySelectorAll('ms-chat-turn');
if (turns && turns.length) {
turns.forEach((turn) => {
const userContainer = turn.querySelector('.chat-turn-container.user');
if (!userContainer) return;
const userNode = turn.querySelector('.turn-content ms-cmark-node');
const text = (userNode ? userNode.innerText : turn.innerText) || '';
const cleaned = text.trim().replace(/\s+/g, ' ');
if (!cleaned) return;
const anchorId = ensureConversationAnchor(turn);
if (!anchorId) return;
results.push({ anchorId, text: cleaned });
});
}
return results;
}
function renderConversationDirectoryItems(items) {
safeSetInnerHTML(conversationDirectoryContainer, '');
if (!items.length) {
const empty = document.createElement('div');
empty.textContent = '未检测到用户提问';
empty.style.cssText = 'padding: 10px; color: var(--ge-text-muted-2); font-size: 12px;';
conversationDirectoryContainer.appendChild(empty);
return;
}
items.forEach((item, idx) => {
const row = document.createElement('div');
row.className = 'gemini-conversation-directory-item';
row.dataset.anchorId = item.anchorId;
const preview = item.text.replace(/\s+/g, ' ').trim();
const shortText = preview.length > 60 ? `${preview.slice(0, 60)}...` : preview;
row.textContent = `${idx + 1}. ${shortText}`;
conversationDirectoryContainer.appendChild(row);
});
}
function updateConversationDirectory() {
if (!conversationDirectoryContainer) return;
const items = collectUserPromptsForDirectory();
// 目录签名:包含文本片段,确保同一锚点内容补全时也能刷新
const signature = items.map(i => `${i.anchorId}:${i.text.slice(0, 80)}`).join('|');
if (signature === conversationDirectoryLastSignature) return;
conversationDirectoryLastSignature = signature;
renderConversationDirectoryItems(items);
}
function startConversationDirectoryObserver() {
if (conversationDirectoryObserver) conversationDirectoryObserver.disconnect();
const root = document.querySelector('#chat-history') || document.body;
conversationDirectoryObserver = new MutationObserver(() => {
scheduleConversationDirectoryUpdate(150);
});
conversationDirectoryObserver.observe(root, { childList: true, subtree: true });
}
// 目录面板位置持久化
function loadDirectoryPosition() {
try {
const saved = localStorage.getItem(DIRECTORY_POS_KEY);
if (saved) return JSON.parse(saved);
} catch (_) { }
return null;
}
function saveDirectoryPosition(top, right) {
try {
localStorage.setItem(DIRECTORY_POS_KEY, JSON.stringify({ top, right }));
} catch (_) { }
}
function loadDirectoryCollapsed() {
try {
return localStorage.getItem(DIRECTORY_COLLAPSED_KEY) === 'true';
} catch (_) { }
return false;
}
function saveDirectoryCollapsed(collapsed) {
try {
localStorage.setItem(DIRECTORY_COLLAPSED_KEY, collapsed ? 'true' : 'false');
} catch (_) { }
}
// 目录面板折叠切换
function toggleDirectoryCollapse() {
if (!conversationDirectoryPanel || !conversationDirectoryContainer) return;
directoryCollapsed = !directoryCollapsed;
conversationDirectoryContainer.style.display = directoryCollapsed ? 'none' : 'block';
const toggleBtn = conversationDirectoryPanel.querySelector('.directory-toggle-btn');
if (toggleBtn) toggleBtn.textContent = directoryCollapsed ? '+' : '-';
saveDirectoryCollapsed(directoryCollapsed);
}
// 目录面板拖拽
function initDirectoryDrag() {
if (!conversationDirectoryPanel) return;
const header = conversationDirectoryPanel.querySelector('.directory-header');
if (!header) return;
header.style.cursor = 'move';
header.addEventListener('mousedown', (e) => {
// 点击折叠按钮时不启动拖拽
if (e.target.classList.contains('directory-toggle-btn')) return;
e.preventDefault();
const rect = conversationDirectoryPanel.getBoundingClientRect();
directoryDragState = {
isDragging: true,
startX: e.clientX,
startY: e.clientY,
startTop: rect.top,
startRight: window.innerWidth - rect.right
};
conversationDirectoryPanel.style.transition = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!directoryDragState.isDragging) return;
const deltaX = e.clientX - directoryDragState.startX;
const deltaY = e.clientY - directoryDragState.startY;
let newTop = directoryDragState.startTop + deltaY;
let newRight = directoryDragState.startRight - deltaX;
// 边界限制
newTop = Math.max(10, Math.min(window.innerHeight - 100, newTop));
newRight = Math.max(10, Math.min(window.innerWidth - 100, newRight));
conversationDirectoryPanel.style.top = newTop + 'px';
conversationDirectoryPanel.style.right = newRight + 'px';
});
document.addEventListener('mouseup', () => {
if (!directoryDragState.isDragging) return;
directoryDragState.isDragging = false;
conversationDirectoryPanel.style.transition = '';
// 保存位置
const top = parseInt(conversationDirectoryPanel.style.top, 10);
const right = parseInt(conversationDirectoryPanel.style.right, 10);
saveDirectoryPosition(top, right);
});
}
// --- UI 界面创建与更新 ---
function createUI() {
console.log("开始创建 UI 元素...");
// 创建右侧折叠按钮
toggleButton = document.createElement('div');
toggleButton.id = 'gemini-export-toggle';
safeSetInnerHTML(toggleButton, '<');
toggleButton.style.cssText = `
position: fixed;
top: 50%;
right: 0;
width: 40px;
height: 60px;
background: var(--ge-gradient-primary);
color: var(--ge-on-primary);
border: 1px solid rgba(255, 255, 255, 0.3);
border-right: none;
border-radius: 16px 0 0 16px;
cursor: pointer;
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
box-shadow: var(--ge-glass-shadow);
backdrop-filter: var(--ge-glass-blur);
-webkit-backdrop-filter: var(--ge-glass-blur);
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
transform: translateY(-50%);
`;
document.body.appendChild(toggleButton);
// 创建右侧面板
sidePanel = document.createElement('div');
sidePanel.id = 'gemini-export-panel';
sidePanel.style.cssText = `
position: fixed;
top: 0;
right: -420px;
width: 400px;
height: 100vh;
background: var(--ge-panel-bg);
backdrop-filter: var(--ge-glass-blur);
-webkit-backdrop-filter: var(--ge-glass-blur);
border-left: 1px solid var(--ge-border);
z-index: 10000;
transition: right 200ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--ge-glass-shadow);
overflow-y: auto;
font-family: var(--ge-font-family);
`;
document.body.appendChild(sidePanel);
// 创建对话目录面板(独立于折叠侧栏,支持折叠和拖拽)
conversationDirectoryPanel = document.createElement('div');
conversationDirectoryPanel.id = 'gemini-conversation-directory-panel';
// 加载保存的位置
const savedPos = loadDirectoryPosition();
const initTop = savedPos?.top ?? 90;
const initRight = savedPos?.right ?? 44;
conversationDirectoryPanel.style.cssText = `
position: fixed;
top: ${initTop}px;
right: ${initRight}px;
width: 280px;
max-height: 400px;
background: var(--ge-panel-bg);
backdrop-filter: var(--ge-glass-blur);
-webkit-backdrop-filter: var(--ge-glass-blur);
border: 1px solid var(--ge-border);
border-radius: 12px;
z-index: 9999;
overflow: hidden;
font-family: var(--ge-font-family);
transition: right 200ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--ge-glass-shadow);
`;
// 加载折叠状态
directoryCollapsed = loadDirectoryCollapsed();
safeSetInnerHTML(conversationDirectoryPanel, `
`);
document.body.appendChild(conversationDirectoryPanel);
// 绑定折叠按钮事件
const toggleBtn = conversationDirectoryPanel.querySelector('.directory-toggle-btn');
if (toggleBtn) {
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleDirectoryCollapse();
});
}
// 面板内容
safeSetInnerHTML(sidePanel, `
一键导出聊天记录与 Canvas 内容
使用提示
导出前建议先滚动到对话顶部,避免缺失
如页面结构更新导致无法识别,请更新选择器
v1.2.0 | sxuan © 2025-2026
GitHub
`);
// 获取元素引用
captureButtonScroll = document.getElementById('capture-chat-scroll-button');
captureButtonCanvas = document.getElementById('capture-canvas-button');
captureButtonCombined = document.getElementById('capture-combined-button');
stopButtonScroll = document.getElementById('stop-scrolling-button');
statusDiv = document.getElementById('extract-status-div');
formatSelector = document.getElementById('format-selector');
conversationDirectoryContainer = document.getElementById('conversation-directory');
// 初始化格式选择器
initFormatSelector();
// 添加事件监听器
captureButtonScroll.addEventListener('click', handleScrollExtraction);
captureButtonCanvas.addEventListener('click', handleCanvasExtraction);
captureButtonCombined.addEventListener('click', handleCombinedExtraction);
stopButtonScroll.addEventListener('click', () => {
if (isScrolling) {
updateStatus('手动停止滚动信号已发送..');
isScrolling = false;
stopButtonScroll.disabled = true;
stopButtonScroll.textContent = '正在停止...';
}
});
// 折叠按钮点击事件
toggleButton.addEventListener('click', togglePanel);
conversationDirectoryContainer.addEventListener('click', (event) => {
const target = event.target.closest('.gemini-conversation-directory-item');
if (!target) return;
const anchorId = target.dataset.anchorId;
if (!anchorId) return;
const anchorEl = document.getElementById(anchorId);
if (!anchorEl) return;
anchorEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.classList.add('active');
window.setTimeout(() => target.classList.remove('active'), 1200);
});
// 添加样式
GM_addStyle(`
/* 胶囊按钮悬停效果 */
.aihub-button:hover {
transform: translateY(-2px);
filter: brightness(1.08);
}
.aihub-button:active {
transform: translateY(0);
filter: brightness(0.95);
}
.aihub-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
filter: grayscale(0.5) !important;
}
/* 主按钮 */
.aihub-button-primary:hover {
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5) !important;
}
/* 成功按钮 */
.aihub-button-success:hover {
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.5) !important;
}
/* 危险按钮 */
.aihub-button-danger:hover {
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.5) !important;
}
/* 成功/错误状态 */
.success {
background: var(--ge-gradient-success) !important;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4) !important;
}
.error {
background: var(--ge-gradient-danger) !important;
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4) !important;
}
/* 格式选项悬停 */
.format-option:hover {
border-color: var(--ge-border-hover) !important;
background: var(--ge-surface-hover) !important;
}
.format-option.selected {
border-color: var(--ge-primary) !important;
background: rgba(59, 130, 246, 0.15) !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* 触发器悬停 */
#gemini-export-toggle:hover {
right: 8px;
transform: translateY(-50%) scale(1.05);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
/* 面板滚动条 */
#gemini-export-panel::-webkit-scrollbar {
width: 6px;
}
#gemini-export-panel::-webkit-scrollbar-track {
background: transparent;
}
#gemini-export-panel::-webkit-scrollbar-thumb {
background: var(--ge-scroll-thumb);
border-radius: 3px;
}
#gemini-export-panel::-webkit-scrollbar-thumb:hover {
background: var(--ge-scroll-thumb-hover);
}
/* 目录滚动条 */
#conversation-directory::-webkit-scrollbar {
width: 5px;
}
#conversation-directory::-webkit-scrollbar-track {
background: transparent;
}
#conversation-directory::-webkit-scrollbar-thumb {
background: var(--ge-scroll-thumb);
border-radius: 3px;
}
#conversation-directory::-webkit-scrollbar-thumb:hover {
background: var(--ge-scroll-thumb-hover);
}
/* 目录项 */
.gemini-conversation-directory-item {
padding: 10px 12px;
font-size: 12px;
line-height: 1.4;
color: var(--ge-panel-text);
border-bottom: 1px solid var(--ge-divider);
cursor: pointer;
transition: all 150ms ease;
}
.gemini-conversation-directory-item:hover {
background: var(--ge-surface-hover);
padding-left: 16px;
}
.gemini-conversation-directory-item.active {
background: rgba(59, 130, 246, 0.15);
border-left: 2px solid var(--ge-primary);
padding-left: 14px;
}
/* 目录折叠按钮 */
.directory-toggle-btn:hover {
background: var(--ge-surface-hover) !important;
}
.directory-header:hover {
background: var(--ge-surface);
}
/* Toast 样式 */
.aihub-toast {
padding: 12px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.25);
animation: aihub-toast-in 300ms cubic-bezier(0.4, 0, 0.2, 1);
max-width: 320px;
}
.aihub-toast-success {
background: rgba(16, 185, 129, 0.9);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.aihub-toast-error {
background: rgba(239, 68, 68, 0.9);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.aihub-toast-info {
background: rgba(59, 130, 246, 0.9);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
@keyframes aihub-toast-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes aihub-toast-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
/* 无障碍:减少动画 */
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* 焦点样式 */
:focus-visible {
outline: 2px solid var(--ge-primary);
outline-offset: 2px;
}
`);
startConversationDirectoryObserver();
scheduleConversationDirectoryUpdate(0);
updateConversationDirectoryPanelPosition();
initDirectoryDrag();
console.log("UI 元素创建完成");
}
// 格式选择器初始化
function initFormatSelector() {
const options = formatSelector.querySelectorAll('.format-option');
const currentFormat = window.__GEMINI_EXPORT_FORMAT || 'txt';
// 设置初始选中状态
options.forEach(option => {
if (option.dataset.format === currentFormat) {
option.classList.add('selected');
}
// 添加点击事件
option.addEventListener('click', () => {
options.forEach(opt => opt.classList.remove('selected'));
option.classList.add('selected');
window.__GEMINI_EXPORT_FORMAT = option.dataset.format;
updateStatus(`导出格式已切换为: ${option.dataset.format.toUpperCase()}`);
// 2秒后清除状态信息
setTimeout(() => {
if (statusDiv.textContent.includes('导出格式已切换')) {
updateStatus('');
}
}, 2000);
});
});
}
// 折叠面板切换
function togglePanel() {
const isOpen = sidePanel.style.right === '0px';
if (isOpen) {
sidePanel.style.right = '-420px';
safeSetInnerHTML(toggleButton, '<');
toggleButton.style.right = '0';
} else {
sidePanel.style.right = '0px';
safeSetInnerHTML(toggleButton, '>');
toggleButton.style.right = '420px';
}
updateConversationDirectoryPanelPosition();
}
function updateConversationDirectoryPanelPosition() {
if (!conversationDirectoryPanel || !sidePanel) return;
const savedPos = loadDirectoryPosition();
if (savedPos) return;
const isOpen = sidePanel.style.right === '0px';
conversationDirectoryPanel.style.right = isOpen ? '420px' : '44px';
}
function updateStatus(message) {
if (statusDiv) {
statusDiv.textContent = message;
statusDiv.style.display = message ? 'block' : 'none';
}
console.log(`[Status] ${message}`);
}
// Toast 通知系统
function showToast(message, type = 'info', duration = 3000) {
if (!toastContainer) {
toastContainer = document.getElementById('aihub-toast-container');
}
if (!toastContainer) return;
const toast = document.createElement('div');
toast.className = `aihub-toast aihub-toast-${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
// 自动移除
setTimeout(() => {
toast.style.animation = 'aihub-toast-out 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards';
setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 300);
}, duration);
}
// --- 核心业务逻辑 (滚动导出) ---
// Canvas 提取
async function extractCanvasContent() {
console.log("开始提取 Canvas 内容...");
const canvasData = [];
const seen = new Set();
const wait = (ms) => new Promise(r => setTimeout(r, ms));
// 滚动提取 Monaco 内容
async function extractScrollableMonaco(panel) {
try {
const scrollable = panel.querySelector('.monaco-scrollable-element');
const linesContainer = panel.querySelector('.view-lines, .lines-content');
if (!scrollable || !linesContainer) return null;
const { scrollHeight, clientHeight } = scrollable;
if (scrollHeight <= clientHeight + 50) return null;
const originalScrollTop = scrollable.scrollTop;
const lineMap = new Map();
let currentScroll = 0;
const maxAttempts = 150;
for (let i = 0; i < maxAttempts && currentScroll < scrollHeight; i++) {
scrollable.scrollTop = currentScroll;
await wait(80);
const lines = linesContainer.querySelectorAll('.view-line');
lines.forEach(line => {
const top = parseInt(line.style.top || '0', 10);
if (!isNaN(top)) lineMap.set(top, line.textContent || '');
});
currentScroll += clientHeight;
}
scrollable.scrollTop = originalScrollTop;
if (lineMap.size === 0) return null;
return Array.from(lineMap.entries()).sort((a, b) => a[0] - b[0]).map(e => e[1]).join('\n');
} catch (e) {
console.error('Scroll extraction failed', e);
return null;
}
}
// 查找 immersive-panel
const panels = Array.from(document.querySelectorAll('immersive-panel, code-immersive-panel'));
let targetPanel = panels.find(p => {
const rect = p.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}) || panels[0];
if (targetPanel) {
// 切换代码模式
const tabGroup = targetPanel.querySelector('mat-button-toggle-group');
if (tabGroup) {
const codeTab = Array.from(tabGroup.querySelectorAll('mat-button-toggle')).find(
tab => tab.textContent?.includes('代码') || tab.textContent?.toLowerCase().includes('code')
);
if (codeTab && !codeTab.classList.contains('mat-button-toggle-checked')) {
const btn = codeTab.querySelector('button');
if (btn) btn.click();
let attempts = 0;
while (attempts < 30) {
await wait(100);
if (targetPanel.querySelectorAll('.view-line').length > 5) break;
attempts++;
}
}
}
let codeContent = '';
updateStatus('正在扫描 Canvas 代码...');
const scrolledContent = await extractScrollableMonaco(targetPanel);
if (scrolledContent) codeContent = scrolledContent;
if (!codeContent) {
let viewLines = targetPanel.querySelectorAll('.view-line');
if (viewLines.length <= 1) { await wait(500); viewLines = targetPanel.querySelectorAll('.view-line'); }
if (viewLines.length > 0) {
codeContent = Array.from(viewLines).map(line => line.textContent || '').join('\n').trim();
}
if (!codeContent) {
const rawEl = targetPanel.querySelector('.lines-content, .monaco-scrollable-element');
if (rawEl) codeContent = (rawEl.textContent || '').trim();
}
if (!codeContent) {
const monacoEditor = targetPanel.querySelector('.monaco-editor');
if (monacoEditor) codeContent = (monacoEditor.textContent || '').trim();
}
}
if (codeContent) {
const key = codeContent.substring(0, 100);
if (!seen.has(key)) {
seen.add(key);
const langHint = targetPanel.querySelector('[data-mode-id]')?.getAttribute('data-mode-id')
|| targetPanel.querySelector('.detected-link')?.textContent?.toLowerCase()
|| 'html';
canvasData.push({
type: 'code', index: canvasData.length + 1, content: codeContent.trim(), language: langHint, source: 'canvas'
});
}
}
}
// 提取代码块
const codeBlocks = document.querySelectorAll('code-block, pre code, .code-block');
codeBlocks.forEach((block) => {
if (block.closest('immersive-panel, code-immersive-panel')) return;
const codeContent = block.textContent || block.innerText;
if (!codeContent || !codeContent.trim()) return;
const trimmedContent = codeContent.trim();
const key = trimmedContent.substring(0, 100);
if (seen.has(key)) return;
seen.add(key);
canvasData.push({
type: 'code',
index: canvasData.length + 1,
content: trimmedContent,
language: block.querySelector('[data-lang]')?.getAttribute('data-lang') || 'unknown',
source: 'code-block'
});
});
// 提取响应文本
if (canvasData.length === 0) {
const responseElements = document.querySelectorAll('.markdown, .model-response-text');
responseElements.forEach((element) => {
if (element.closest('code-block') || element.querySelector('code-block')) return;
const textContent = element.textContent || element.innerText;
if (!textContent || !textContent.trim()) return;
const trimmedContent = textContent.trim();
const key = trimmedContent.substring(0, 100);
if (seen.has(key)) return;
seen.add(key);
canvasData.push({
type: 'text',
index: canvasData.length + 1,
content: trimmedContent,
source: 'response'
});
});
}
console.log(`Canvas 内容提取完成,共导出 ${canvasData.length} 个块`);
return canvasData;
}
function formatCanvasDataForExport(canvasData, context) {
const mode = (window.__GEMINI_EXPORT_FORMAT || 'txt').toLowerCase();
const projectName = getProjectName();
const ts = getCurrentTimestamp();
const base = `${projectName}_Canvas_${context}_${ts}`;
function escapeMd(s) {
return s.replace(/`/g, '\u0060').replace(/ {
if (item.type === 'code') {
body += `--- 代码块 ${item.index} (${item.language}) ---\n${item.content}\n\n`;
} else if (item.type === 'text') {
body += `--- 文本内容 ${item.index} ---\n${item.content}\n\n`;
} else {
body += `--- 完整内容 ---\n${item.content}\n\n`;
}
body += "------------------------------\n\n";
});
body = body.replace(/\n\n------------------------------\n\n$/, '\n').trim();
return { blob: new Blob([body], { type: 'text/plain;charset=utf-8' }), filename: `${base}.txt` };
}
if (mode === 'json') {
const jsonData = {
exportType: 'canvas',
timestamp: ts,
projectName: projectName,
content: canvasData
};
return { blob: new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json;charset=utf-8' }), filename: `${base}.json` };
}
if (mode === 'md') {
let md = `# ${projectName} Canvas 内容导出\n\n`;
md += `导出时间:${ts}\n\n`;
canvasData.forEach((item, idx) => {
md += `## 内容块 ${idx + 1}\n\n`;
if (item.type === 'code') {
md += `**代码块** (语言: ${item.language}):\n\n\`\`\`${item.language}\n${item.content}\n\`\`\`\n\n`;
} else if (item.type === 'text') {
md += `**文本内容**:\n\n${escapeMd(item.content)}\n\n`;
} else {
md += `**完整内容**:\n\n${escapeMd(item.content)}\n\n`;
}
md += `---\n\n`;
});
return { blob: new Blob([md], { type: 'text/markdown;charset=utf-8' }), filename: `${base}.md` };
}
}
async function handleCanvasExtraction() {
captureButtonCanvas.disabled = true;
captureButtonCanvas.textContent = buttonTextCanvasProcessing;
try {
updateStatus('正在提取 Canvas 内容...');
const canvasData = await extractCanvasContent();
if (canvasData.length === 0) {
alert('未能找到内容,请确保页面上有代码块。');
captureButtonCanvas.textContent = `${errorTextCanvas}: 无内容`;
captureButtonCanvas.classList.add('error');
} else {
updateStatus(`正在格式化 ${canvasData.length} 个块...`);
const exportData = formatCanvasDataForExport(canvasData, 'export');
const a = document.createElement('a');
const url = URL.createObjectURL(exportData.blob);
a.href = url;
a.download = exportData.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
captureButtonCanvas.textContent = successTextCanvas;
captureButtonCanvas.classList.add('success');
updateStatus(`导出成功: ${exportData.filename}`);
}
} catch (error) {
console.error('Canvas 导出错误:', error);
updateStatus(`错误: ${error.message}`);
captureButtonCanvas.textContent = `${errorTextCanvas}: 处理出错`;
captureButtonCanvas.classList.add('error');
} finally {
setTimeout(() => {
captureButtonCanvas.textContent = buttonTextCanvasExport;
captureButtonCanvas.disabled = false;
captureButtonCanvas.classList.remove('success', 'error');
updateStatus('');
}, exportTimeout);
}
}
// 组合导出
async function handleCombinedExtraction() {
captureButtonCombined.disabled = true;
captureButtonCombined.textContent = buttonTextCombinedProcessing;
try {
updateStatus('1/3: 提取 Canvas...');
const canvasData = await extractCanvasContent();
updateStatus('2/3: 滚动获取对话...');
collectedData.clear();
isScrolling = true;
scrollCount = 0;
noChangeCounter = 0;
stopButtonScroll.style.display = 'block';
stopButtonScroll.disabled = false;
stopButtonScroll.textContent = buttonTextStopScroll;
const scroller = getMainScrollerElement_AiStudio();
if (scroller) {
const isWindowScroller = (scroller === document.documentElement || scroller === document.body);
if (isWindowScroller) window.scrollTo({ top: 0, behavior: 'smooth' });
else scroller.scrollTo({ top: 0, behavior: 'smooth' });
await delay(1500);
}
const scrollSuccess = await autoScrollDown_AiStudio();
if (scrollSuccess !== false) {
updateStatus('2/3: 处理对话数据...');
await delay(500);
extractDataIncremental_AiStudio();
await delay(200);
} else {
throw new Error('滚动失败');
}
updateStatus('3/3: 合并导出...');
let scrollData = [];
if (document.querySelector('#chat-history .conversation-container')) {
const cs = document.querySelectorAll('#chat-history .conversation-container');
cs.forEach(c => { if (collectedData.has(c)) scrollData.push(collectedData.get(c)); });
} else {
const turns = document.querySelectorAll('ms-chat-turn');
turns.forEach(t => { if (collectedData.has(t)) scrollData.push(collectedData.get(t)); });
}
const combinedData = formatCombinedDataForExport(scrollData, canvasData);
const a = document.createElement('a');
const url = URL.createObjectURL(combinedData.blob);
a.href = url;
a.download = combinedData.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
captureButtonCombined.textContent = successTextCombined;
captureButtonCombined.classList.add('success');
updateStatus(`成功: ${combinedData.filename}`);
} catch (error) {
console.error('组合导出错误:', error);
updateStatus(`错误: ${error.message}`);
captureButtonCombined.textContent = `${errorTextCombined}: 出错`;
captureButtonCombined.classList.add('error');
} finally {
stopButtonScroll.style.display = 'none';
isScrolling = false;
setTimeout(() => {
captureButtonCombined.textContent = buttonTextCombinedExport;
captureButtonCombined.disabled = false;
captureButtonCombined.classList.remove('success', 'error');
updateStatus('');
}, exportTimeout);
}
}
// 组合数据格式化和导出函数
function formatCombinedDataForExport(scrollData, canvasData) {
const mode = (window.__GEMINI_EXPORT_FORMAT || 'txt').toLowerCase();
const projectName = getProjectName();
const ts = getCurrentTimestamp();
const base = `${projectName}_Combined_${ts}`;
function escapeMd(s) {
return s.replace(/`/g, '\u0060').replace(/ {
// 创建内容的唯一标识符
const contentKey = [
item.userText || '',
item.thoughtText || '',
item.responseText || ''
].join('|||').substring(0, 200); // 使用前200个字符作为唯一性标识
if (!seen.has(contentKey)) {
seen.add(contentKey);
deduplicated.push(item);
}
});
return deduplicated;
}
// 去重处理
const deduplicatedScrollData = deduplicateScrollData(scrollData);
if (mode === 'txt') {
let body = `Gemini 组合导出 (对话 + Canvas)
=========================================
`;
// 添加对话内容
if (deduplicatedScrollData && deduplicatedScrollData.length > 0) {
body += `=== 对话内容 ===
`;
deduplicatedScrollData.forEach(item => {
let block = '';
if (item.userText) block += `--- 用户 ---\n${item.userText}\n\n`;
if (item.thoughtText) block += `--- AI 思维链 ---\n${item.thoughtText}\n\n`;
if (item.responseText) block += `--- AI 回答 ---\n${item.responseText}\n\n`;
body += block.trim() + "\n\n------------------------------\n\n";
});
}
// 添加Canvas内容
if (canvasData && canvasData.length > 0) {
body += `\n\n=== Canvas 内容 ===\n\n`;
canvasData.forEach(item => {
if (item.type === 'code') {
body += `--- 代码块 ${item.index} (${item.language}) ---\n${item.content}\n\n`;
} else if (item.type === 'text') {
body += `--- 文本内容 ${item.index} ---\n${item.content}\n\n`;
} else {
body += `--- 完整内容 ---\n${item.content}\n\n`;
}
body += "------------------------------\n\n";
});
}
body = body.replace(/\n\n------------------------------\n\n$/, '\n').trim();
return { blob: new Blob([body], { type: 'text/plain;charset=utf-8' }), filename: `${base}.txt` };
}
if (mode === 'json') {
const jsonData = {
exportType: 'combined',
timestamp: ts,
projectName: projectName,
dialogue: [],
canvas: canvasData || []
};
// 添加对话数据
if (deduplicatedScrollData && deduplicatedScrollData.length > 0) {
deduplicatedScrollData.forEach(item => {
if (item.userText) jsonData.dialogue.push({ role: 'user', content: item.userText, id: `${item.domOrder}-user` });
if (item.thoughtText) jsonData.dialogue.push({ role: 'thought', content: item.thoughtText, id: `${item.domOrder}-thought` });
if (item.responseText) jsonData.dialogue.push({ role: 'assistant', content: item.responseText, id: `${item.domOrder}-assistant` });
});
}
return { blob: new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json;charset=utf-8' }), filename: `${base}.json` };
}
if (mode === 'md') {
let md = `# ${projectName} 组合导出
导出时间:${ts}
`;
// 添加对话内容
if (deduplicatedScrollData && deduplicatedScrollData.length > 0) {
md += `## 对话内容
`;
deduplicatedScrollData.forEach((item, idx) => {
md += `### 回合 ${idx + 1}
`;
if (item.userText) md += `**用户**:
${escapeMd(item.userText)}
`;
if (item.thoughtText) md += `AI 思维链
${escapeMd(item.thoughtText)}
`;
if (item.responseText) md += `**AI 回答**:
${escapeMd(item.responseText)}
`;
md += `---
`;
});
}
// 添加Canvas内容
if (canvasData && canvasData.length > 0) {
md += `## Canvas 内容
`;
canvasData.forEach((item, idx) => {
md += `### 内容块 ${idx + 1}
`;
if (item.type === 'code') {
md += `**代码块** (语言: ${item.language}):
\`\`\`${item.language}
${item.content}
\`\`\`
`;
} else if (item.type === 'text') {
md += `**文本内容**:
${escapeMd(item.content)}
`;
} else {
md += `**完整内容**:
${escapeMd(item.content)}
`;
}
md += `---
`;
});
}
return { blob: new Blob([md], { type: 'text/markdown;charset=utf-8' }), filename: `${base}.md` };
}
}
function extractDataIncremental_AiStudio() {
let newlyFoundCount = 0;
let dataUpdatedInExistingTurn = false;
const currentTurns = document.querySelectorAll('ms-chat-turn');
const seenUserTexts = new Set(); // 用于去重用户消息
currentTurns.forEach((turn, index) => {
const turnKey = turn;
const turnContainer = turn.querySelector('.chat-turn-container.user, .chat-turn-container.model');
if (!turnContainer) {
return;
}
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;
if (turnContainer.classList.contains('user')) {
if (extractedInfo.type === 'unknown') extractedInfo.type = 'user';
if (!extractedInfo.userText) {
let userNode = turn.querySelector('.turn-content ms-cmark-node');
let userText = userNode ? userNode.innerText.trim() : null;
if (userText) {
// 检查是否已经存在相同的用户消息
if (!seenUserTexts.has(userText)) {
seenUserTexts.add(userText);
extractedInfo.userText = userText;
dataWasUpdatedThisTime = true;
}
}
}
} else if (turnContainer.classList.contains('model')) {
if (extractedInfo.type === 'unknown') extractedInfo.type = 'model';
if (!extractedInfo.thoughtText) {
let thoughtNode = turn.querySelector('.thought-container .mat-expansion-panel-body');
if (thoughtNode) {
let thoughtText = thoughtNode.textContent.trim();
if (thoughtText && thoughtText.toLowerCase() !== 'thinking process:') {
extractedInfo.thoughtText = thoughtText;
dataWasUpdatedThisTime = true;
}
}
}
if (!extractedInfo.responseText) {
const responseChunks = Array.from(turn.querySelectorAll('.turn-content > ms-prompt-chunk'));
const responseTexts = responseChunks
.filter(chunk => !chunk.querySelector('.thought-container'))
.map(chunk => {
const cmarkNode = chunk.querySelector('ms-cmark-node');
return cmarkNode ? cmarkNode.innerText.trim() : chunk.innerText.trim();
})
.filter(text => text);
if (responseTexts.length > 0) {
extractedInfo.responseText = responseTexts.join('\n\n');
dataWasUpdatedThisTime = true;
} else if (!extractedInfo.thoughtText) {
const turnContent = turn.querySelector('.turn-content');
if (turnContent) {
extractedInfo.responseText = turnContent.innerText.trim();
dataWasUpdatedThisTime = true;
}
}
}
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';
}
}
if (dataWasUpdatedThisTime) {
collectedData.set(turnKey, extractedInfo);
dataUpdatedInExistingTurn = true;
}
});
if (currentTurns.length > 0 && collectedData.size === 0) {
console.warn("警告(滚动导出): 页面上存在聊天回合(ms-chat-turn),但未能提取任何数据。CSS选择器可能已完全失效,请按F12检查并更新 extractDataIncremental_Gemini 函数中的选择器。");
updateStatus(`警告: 无法从聊天记录中提取数据,请检查脚本!`);
} else {
updateStatus(`滚动 ${scrollCount}/${MAX_SCROLL_ATTEMPTS}... 已收集 ${collectedData.size} 条记录。`);
}
scheduleConversationDirectoryUpdate(0);
return newlyFoundCount > 0 || dataUpdatedInExistingTurn;
}
async function autoScrollDown_AiStudio() {
console.log("启动自动滚动 (滚动导出)...");
isScrolling = true; collectedData.clear(); scrollCount = 0; noChangeCounter = 0;
const scroller = getMainScrollerElement_AiStudio();
if (!scroller) {
updateStatus('错误 (滚动): 找不到滚动区域');
alert('未能找到聊天记录的滚动区域,无法自动滚动。请检查脚本中的选择器。');
isScrolling = false; return false;
}
console.log('使用的滚动元素(滚动导出):', scroller);
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;
updateStatus(`开始增量滚动(最多 ${MAX_SCROLL_ATTEMPTS} 次)...`);
let lastScrollHeight = -1;
while (scrollCount < MAX_SCROLL_ATTEMPTS && isScrolling) {
const currentScrollTop = getScrollTop(); const currentScrollHeight = getScrollHeight(); const currentClientHeight = getClientHeight();
if (currentScrollHeight === lastScrollHeight) { noChangeCounter++; } else { noChangeCounter = 0; }
lastScrollHeight = currentScrollHeight;
if (noChangeCounter >= SCROLL_STABILITY_CHECKS && currentScrollTop + currentClientHeight >= currentScrollHeight - 20) {
console.log("滚动条疑似触底(滚动导出),停止滚动。");
updateStatus(`滚动完成 (疑似触底)。`);
break;
}
if (currentScrollTop === 0 && scrollCount > 10) {
console.log("滚动条返回顶部(滚动导出),停止滚动。");
updateStatus(`滚动完成 (返回顶部)。`);
break;
}
const targetScrollTop = currentScrollTop + (currentClientHeight * SCROLL_INCREMENT_FACTOR);
if (isWindowScroller) { window.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); } else { scroller.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); }
scrollCount++;
updateStatus(`滚动 ${scrollCount}/${MAX_SCROLL_ATTEMPTS}... 等待 ${SCROLL_DELAY_MS}ms... (已收集 ${collectedData.size} 条记录。)`);
await delay(SCROLL_DELAY_MS);
// 使用统一调度:优先 Gemini 结构,其次 AI Studio
try { extractDataIncremental_Dispatch(); } catch (e) { console.warn('调度提取失败,回退 AI Studio 提取', e); try { extractDataIncremental_AiStudio(); } catch (_) { } }
if (!isScrolling) {
console.log("检测到手动停止信号 (滚动导出),退出滚动循环。"); break;
}
}
if (!isScrolling && scrollCount < MAX_SCROLL_ATTEMPTS) {
updateStatus(`滚动已手动停止 (已滚动 ${scrollCount} 次)。`);
} else if (scrollCount >= MAX_SCROLL_ATTEMPTS) {
updateStatus(`滚动停止: 已达到最大尝试次数 (${MAX_SCROLL_ATTEMPTS})。`);
}
isScrolling = false;
return true;
}
function formatAndExport(sortedData, context) { // 多格式骨架
const mode = (window.__GEMINI_EXPORT_FORMAT || 'txt').toLowerCase();
const projectName = getProjectName();
const ts = getCurrentTimestamp();
const base = `${projectName}_${context}_${ts}`;
// 对数据进行去重处理
function deduplicateData(data) {
if (!data || !Array.isArray(data)) return [];
const seen = new Set();
const deduplicated = [];
data.forEach(item => {
// 创建内容的唯一标识符
const contentKey = [
item.userText || '',
item.thoughtText || '',
item.responseText || ''
].join('|||').substring(0, 200); // 使用前200个字符作为唯一性标识
if (!seen.has(contentKey)) {
seen.add(contentKey);
deduplicated.push(item);
}
});
return deduplicated;
}
// 去重处理
const deduplicatedData = deduplicateData(sortedData);
function escapeMd(s) {
return s.replace(/`/g, '\u0060').replace(/ {
let block = '';
if (item.userText) block += `--- 用户 ---\n${item.userText}\n\n`;
if (item.thoughtText) block += `--- AI 思维链 ---\n${item.thoughtText}\n\n`;
if (item.responseText) block += `--- AI 回答 ---\n${item.responseText}\n\n`;
if (!block) {
block = '--- 回合 (内容提取不完整或失败) ---\n';
if (item.thoughtText) block += `思维链(可能不全): ${item.thoughtText}\n`;
if (item.responseText) block += `回答(可能不全): ${item.responseText}\n`;
block += '\n';
}
body += block.trim() + "\n\n------------------------------\n\n";
});
body = body.replace(/\n\n------------------------------\n\n$/, '\n').trim();
return { blob: new Blob([body], { type: 'text/plain;charset=utf-8' }), filename: `${base}.txt` };
}
if (mode === 'json') {
let arr = [];
deduplicatedData.forEach(item => {
if (item.userText) arr.push({ role: 'user', content: item.userText, id: `${item.domOrder}-user` });
if (item.thoughtText) arr.push({ role: 'thought', content: item.thoughtText, id: `${item.domOrder}-thought` });
if (item.responseText) arr.push({ role: 'assistant', content: item.responseText, id: `${item.domOrder}-assistant` });
});
return { blob: new Blob([JSON.stringify(arr, null, 2)], { type: 'application/json;charset=utf-8' }), filename: `${base}.json` };
}
if (mode === 'md') { // 正式 Markdown 格式
let md = `# ${projectName} 对话导出 (${context})\n\n`;
md += `导出时间:${ts}\n\n`;
deduplicatedData.forEach((item, idx) => {
md += `## 回合 ${idx + 1}\n\n`;
if (item.userText) md += `**用户**:\n\n${escapeMd(item.userText)}\n\n`;
if (item.thoughtText) md += `AI 思维链
\n\n${escapeMd(item.thoughtText)}\n\n \n\n`;
if (item.responseText) md += `**AI 回答**:\n\n${escapeMd(item.responseText)}\n\n`;
md += `---\n\n`;
});
return { blob: new Blob([md], { type: 'text/markdown;charset=utf-8' }), filename: `${base}.md` };
}
}
function formatAndTriggerDownloadScroll() { // 统一调度 Gemini/AI Studio
updateStatus(`处理 ${collectedData.size} 条滚动记录并生成文件...`);
let sorted = [];
if (document.querySelector('#chat-history .conversation-container')) {
const cs = document.querySelectorAll('#chat-history .conversation-container');
cs.forEach(c => { if (collectedData.has(c)) sorted.push(collectedData.get(c)); });
} else {
const turns = document.querySelectorAll('ms-chat-turn');
turns.forEach(t => { if (collectedData.has(t)) sorted.push(collectedData.get(t)); });
}
if (!sorted.length) {
updateStatus('没有收集到任何有效滚动记录。');
alert('滚动结束后未能收集到任何聊天记录,无法导出。');
captureButtonScroll.textContent = buttonTextStartScroll; captureButtonScroll.disabled = false; captureButtonScroll.classList.remove('success', 'error'); updateStatus('');
return;
}
try {
const pack = formatAndExport(sorted, 'scroll');
const a = document.createElement('a');
const url = URL.createObjectURL(pack.blob);
a.href = url; a.download = pack.filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
captureButtonScroll.textContent = successTextScroll; captureButtonScroll.classList.add('success');
} catch (e) {
console.error('滚动导出文件失败:', e);
captureButtonScroll.textContent = `${errorTextScroll}: 创建失败`; captureButtonScroll.classList.add('error'); alert('创建滚动下载文件时出错: ' + e.message);
}
setTimeout(() => { captureButtonScroll.textContent = buttonTextStartScroll; captureButtonScroll.disabled = false; captureButtonScroll.classList.remove('success', 'error'); updateStatus(''); }, exportTimeout);
}
// TODO 2025-09-08: 后续可实现自动展开 Gemini 隐藏思维链(需要模拟点击“显示思路”按钮),当前以占位符标记
// TODO 2025-09-08: Markdown 正式格式化尚未实现,当前仅输出占位头部,保持向后兼容
async function handleScrollExtraction() {
if (isScrolling) return;
captureButtonScroll.disabled = true;
captureButtonScroll.textContent = '滚动中..';
stopButtonScroll.style.display = 'block';
stopButtonScroll.disabled = false;
stopButtonScroll.textContent = buttonTextStopScroll;
// 在开始前先滚动到页面顶部
const scroller = getMainScrollerElement_AiStudio();
if (scroller) {
updateStatus('正在滚动到顶部..');
const isWindowScroller = (scroller === document.documentElement || scroller === document.body);
if (isWindowScroller) {
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
scroller.scrollTo({ top: 0, behavior: 'smooth' });
}
await delay(1500); // 等待滚动动画完成
}
updateStatus('初始化滚动(滚动导出)...');
try {
const scrollSuccess = await autoScrollDown_AiStudio();
if (scrollSuccess !== false) {
captureButtonScroll.textContent = buttonTextProcessingScroll;
updateStatus('滚动结束,准备最终处理..');
await delay(500);
extractDataIncremental_AiStudio();
await delay(200);
formatAndTriggerDownloadScroll();
} else {
captureButtonScroll.textContent = `${errorTextScroll}: 滚动失败`;
captureButtonScroll.classList.add('error');
setTimeout(() => {
captureButtonScroll.textContent = buttonTextStartScroll;
captureButtonScroll.disabled = false;
captureButtonScroll.classList.remove('error');
updateStatus('');
}, exportTimeout);
}
} catch (error) {
console.error('滚动处理过程中发生错误', error);
updateStatus(`错误 (滚动导出): ${error.message}`);
alert(`滚动处理过程中发生错误: ${error.message}`);
captureButtonScroll.textContent = `${errorTextScroll}: 处理出错`;
captureButtonScroll.classList.add('error');
setTimeout(() => {
captureButtonScroll.textContent = buttonTextStartScroll;
captureButtonScroll.disabled = false;
captureButtonScroll.classList.remove('error');
updateStatus('');
}, exportTimeout);
isScrolling = false;
} finally {
stopButtonScroll.style.display = 'none';
isScrolling = false;
}
}
// --- 脚本初始化入口 ---
console.log("Gemini_Chat_Export 导出脚本 (v1.2.0 - AIhubEnhanced UI): 等待页面加载 (2.5秒)...");
startThemeSync();
setTimeout(createUI, 2500);
})();