// ==UserScript== // @name ChatGPT对话管理工具(支持镜像站) // @namespace https://example.invalid/mirror-chat-clipper // @version 1.0.6 // @description 为 ChatGPT 及镜像站提供对话目录、快捷跳转、范围选择、导出预览、Markdown/HTML 导出与复制等对话管理功能。 // @author nosaying2 // @tag ChatGPT // @tag AI // @tag 对话管理 // @tag 对话导出 // @tag Markdown // @tag 镜像站 // @match https://chat.openai.com/* // @match https://new.oaifree.com/** // @match https://share.github.cn.com/** // @match https://chatgpt.com/** // @match https://*.oaifree.com/** // @match https://cc.plusai.me/** // @match https://chat.chatgptplus.cn/** // @match https://chat.rawchat.cc/** // @match https://rawchat.cn/c/** // @match https://chat.sharedchat.cn/** // @match https://chat.gptdsb.com/** // @match https://chat.freegpts.org/** // @match https://gpt.github.cn.com/** // @match https://chat.aicnn.xyz/** // @match https://*.xyhelper.com.cn/** // @match https://oai.aitopk.com/** // @match https://www.opkfc.com/** // @match https://bus.hematown.com/** // @match https://chatgpt.dairoot.cn/** // @match https://plus.aivvm.com/** // @match https://sharechat.aischat.xyz/** // @match https://web.tu-zi.com/** // @match https://chat1.2233.ai/** // @match https://2233.ai/** // @match https://yesiamai.com/** // @match https://www.azs.ai/** // @match https://sass-node6.chatshare.biz/** // @match https://sass-node1.chatshare.biz/** // @match https://sass-node2.chatshare.biz/** // @match https://sass-node3.chatshare.biz/** // @match https://sass-node4.chatshare.biz/** // @match https://sass-node5.chatshare.biz/** // @match https://oai.253282.xyz/** // @match https://gpt.universalbus.cn/** // @match https://go.gptdsb.com/** // @match https://*.azs.ai/** // @match https://azs.ai/** // @match https://*.azs.ai/c/** // @match https://share.mynanian.top/** // @match https://pro2.ai2.bar/** // @match https://smart.ai1.bar/** // @match https://*/* // @license MIT // @icon https://t1.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&size=32&url=https://chatgpt.com // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (function() { 'use strict'; const OUTLINE_MODE_STORAGE_KEY = 'mirror-chat-clipper-outline-mode'; const OUTLINE_LAUNCHER_POSITION_STORAGE_KEY = 'mirror-chat-clipper-outline-launcher-position'; const CUSTOM_SITE_PATTERNS_STORAGE_KEY = 'mirror-chat-clipper-custom-site-patterns'; const FEEDBACK_URL = 'https://github.com/nosaying2/MirrorChatClipper/issues/new/'; const API_SITE_URL = 'https://aio.folksaying.cn/'; const OUTLINE_MODE_FLOATING = 'floating'; const OUTLINE_MODE_EMBEDDED = 'embedded'; const BUILTIN_SITE_PATTERNS = [ 'chat.openai.com', 'new.oaifree.com', 'share.github.cn.com', 'chatgpt.com', '*.oaifree.com', 'cc.plusai.me', 'chat.chatgptplus.cn', 'chat.rawchat.cc', 'rawchat.cn/c', 'chat.sharedchat.cn', 'chat.gptdsb.com', 'chat.freegpts.org', 'gpt.github.cn.com', 'chat.aicnn.xyz', '*.xyhelper.com.cn', 'oai.aitopk.com', 'www.opkfc.com', 'bus.hematown.com', 'chatgpt.dairoot.cn', 'plus.aivvm.com', 'sharechat.aischat.xyz', 'web.tu-zi.com', 'chat1.2233.ai', '2233.ai', 'yesiamai.com', 'www.azs.ai', 'sass-node6.chatshare.biz', 'sass-node1.chatshare.biz', 'sass-node2.chatshare.biz', 'sass-node3.chatshare.biz', 'sass-node4.chatshare.biz', 'sass-node5.chatshare.biz', 'oai.253282.xyz', 'gpt.universalbus.cn', 'go.gptdsb.com', '*.azs.ai', 'azs.ai', 'share.mynanian.top', 'pro2.ai2.bar', 'smart.ai1.bar' ]; function isCurrentSiteAllowed() { return isSiteAllowed(window.location); } function isSiteAllowed(site) { const current = normalizeSiteLocation(site); if (!current.host) return false; return getAllowedSitePatterns().some(pattern => isSitePatternMatch(current, pattern)); } function getAllowedSitePatterns() { return normalizeSitePatterns(BUILTIN_SITE_PATTERNS.concat(loadCustomSitePatterns())); } function getCurrentSitePattern() { const host = normalizeHostname(window.location.hostname); if (!host) return ''; const pathPrefix = getFirstPathSegment(window.location.pathname); return `${host}${pathPrefix}`; } function loadCustomSitePatterns() { const saved = getUserscriptStoredValue(CUSTOM_SITE_PATTERNS_STORAGE_KEY, []); if (Array.isArray(saved)) return normalizeSitePatterns(saved); if (typeof saved === 'string') { try { const parsed = JSON.parse(saved); if (Array.isArray(parsed)) return normalizeSitePatterns(parsed); } catch (error) { return normalizeSitePatterns(saved.split(/\r?\n/)); } } return []; } function saveCustomSitePatterns(patterns) { setUserscriptStoredValue(CUSTOM_SITE_PATTERNS_STORAGE_KEY, normalizeSitePatterns(patterns)); } function addCustomSitePattern(input) { const pattern = normalizeSitePattern(input); if (!pattern) { return { ok: false, error: '请输入有效的域名、路径前缀或 URL。' }; } const patterns = loadCustomSitePatterns(); if (patterns.includes(pattern) || BUILTIN_SITE_PATTERNS.some(item => item === pattern)) { return {ok: true, pattern, changed: false}; } patterns.push(pattern); saveCustomSitePatterns(patterns); return {ok: true, pattern, changed: true}; } function removeCustomSitePattern(pattern) { const normalized = normalizeSitePattern(pattern); if (!normalized) return; saveCustomSitePatterns(loadCustomSitePatterns().filter(item => item !== normalized)); } function normalizeSitePatterns(patterns) { const unique = []; patterns.forEach(pattern => { const normalized = normalizeSitePattern(pattern); if (normalized && !unique.includes(normalized)) unique.push(normalized); }); return unique; } function normalizeSitePattern(input) { let value = String(input || '').trim(); if (!value) return ''; value = value .replace(/^\/\/\s*@match\s+/i, '') .replace(/^@match\s+/i, '') .replace(/^([a-z][a-z0-9+.-]*|\*):\/\//i, '') .trim() .split(/\s+/)[0] .split(/[?#]/)[0]; const slashIndex = value.indexOf('/'); const rawHost = slashIndex === -1 ? value : value.slice(0, slashIndex); const rawPath = slashIndex === -1 ? '' : value.slice(slashIndex); const host = rawHost .replace(/:\d+$/, '') .replace(/\.$/, '') .toLowerCase(); let hostPattern = ''; if (host.startsWith('*.')) { const base = normalizeHostname(host.slice(2)); hostPattern = base ? `*.${base}` : ''; } else if (!host.includes('*')) { hostPattern = normalizeHostname(host); } if (!hostPattern) return ''; const path = normalizeSitePath(rawPath); if (path === null) return ''; return `${hostPattern}${path}`; } function normalizeSiteLocation(site) { if (site && typeof site === 'object') { return { host: normalizeHostname(site.hostname), path: normalizeCurrentPath(site.pathname) }; } const pattern = normalizeSitePattern(site); return splitSitePattern(pattern); } function splitSitePattern(pattern) { const normalized = String(pattern || ''); const slashIndex = normalized.indexOf('/'); if (slashIndex === -1) { return { host: normalized, path: '' }; } return { host: normalized.slice(0, slashIndex), path: normalized.slice(slashIndex) }; } function normalizeSitePath(path) { let normalized = String(path || '').trim(); if (!normalized || normalized === '/') return ''; if (!normalized.startsWith('/')) normalized = `/${normalized}`; normalized = normalized .replace(/\/\*{1,2}$/, '') .replace(/\/+$/, ''); if (!normalized || normalized === '/') return ''; if (normalized.includes('*')) return null; return normalized; } function normalizeCurrentPath(pathname) { let path = String(pathname || '').split(/[?#]/)[0].trim(); if (!path || path === '/') return ''; if (!path.startsWith('/')) path = `/${path}`; return path.replace(/\/+$/, ''); } function getFirstPathSegment(pathname) { const path = normalizeCurrentPath(pathname); if (!path) return ''; const segment = path.split('/').filter(Boolean)[0]; return segment ? `/${segment}` : ''; } function normalizeHostname(hostname) { const host = String(hostname || '') .trim() .replace(/\.$/, '') .toLowerCase(); if (!host || host.includes('*')) return ''; return isValidHostname(host) ? host : ''; } function isValidHostname(host) { if (host === 'localhost') return true; if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) { return host.split('.').every(part => Number(part) >= 0 && Number(part) <= 255); } const labels = host.split('.'); if (labels.length < 2) return false; return labels.every(label => { if (!label || label.length > 63) return false; if (label.startsWith('-') || label.endsWith('-')) return false; return /^[a-z0-9-]+$/.test(label); }); } function isSitePatternMatch(current, pattern) { const rule = splitSitePattern(pattern); if (!rule.host) return false; if (rule.host.startsWith('*.')) { const base = rule.host.slice(2); if (current.host !== base && !current.host.endsWith(`.${base}`)) return false; } else if (current.host !== rule.host) { return false; } if (!rule.path) return true; return current.path === rule.path || current.path.startsWith(`${rule.path}/`); } function registerSiteAccessMenuCommands() { if (typeof GM_registerMenuCommand !== 'function') return; const currentSite = getCurrentSitePattern(); if (currentSite) { if (isCurrentSiteAllowed()) { GM_registerMenuCommand(`对话管理:当前站点已启用(${currentSite})`, () => { showConversationOutlineSettings('settings'); }); } else { GM_registerMenuCommand(`对话管理:启用当前站点(${currentSite})`, () => { const result = addCustomSitePattern(currentSite); if (!result.ok) { alert(result.error); return; } alert(`已添加 ${result.pattern},当前页会启用对话管理工具。`); startConversationManager(); }); } } GM_registerMenuCommand('对话管理:管理匹配站点', () => { showConversationOutlineSettings('settings'); }); } function getUserscriptStoredValue(key, fallback) { try { if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); } catch (error) { // 继续使用 localStorage 兜底。 } try { const saved = localStorage.getItem(key); return saved ? JSON.parse(saved) : fallback; } catch (error) { return fallback; } } function setUserscriptStoredValue(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } } catch (error) { // 继续使用 localStorage 兜底。 } try { localStorage.setItem(key, JSON.stringify(value)); } catch (error) { // 存储被站点策略禁用时,只影响自定义站点持久化。 } } let isConversationOutlineCollapsed = false; let conversationOutlineUpdatePausedUntil = 0; let conversationOutlineMode = loadConversationOutlineMode(); let conversationOutlineLauncherPosition = loadConversationOutlineLauncherPosition(); let isConversationOutlineLauncherDragging = false; let suppressConversationOutlineLauncherClickUntil = 0; let outlineEmbeddedLayoutTarget = null; let outlineEmbeddedOriginalStyles = null; let conversationOutlineScrollMarginElement = null; let conversationOutlineScrollMarginValue = ''; let conversationOutlineScrollMarginTimer = null; const OUTLINE_FLOATING_Z_INDEX = '9998'; const OUTLINE_EMBEDDED_Z_INDEX = '35'; function createConversationOutline() { if (document.querySelector('#conversation-outline')) return; const wrapper = document.createElement('div'); wrapper.id = 'conversation-outline'; Object.assign(wrapper.style, { position: 'fixed', right: '14px', top: '96px', zIndex: OUTLINE_FLOATING_Z_INDEX, fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }); const panel = document.createElement('aside'); panel.id = 'conversation-outline-panel'; Object.assign(panel.style, { width: '260px', maxHeight: 'calc(100vh - 128px)', overflow: 'hidden', borderRadius: '8px', border: '1px solid rgba(15, 23, 42, 0.12)', background: 'rgba(255, 255, 255, 0.96)', boxShadow: '0 14px 32px rgba(15, 23, 42, 0.16)', backdropFilter: 'blur(8px)', color: '#111827' }); const launcher = document.createElement('button'); launcher.id = 'conversation-outline-launcher'; launcher.type = 'button'; launcher.title = '展开对话目录'; launcher.setAttribute('aria-label', '展开对话目录'); Object.assign(launcher.style, { width: '44px', height: '44px', borderRadius: '50%', border: 'none', display: 'none', alignItems: 'center', justifyContent: 'center', background: '#10a37f', color: '#fff', cursor: 'pointer', fontSize: '20px', boxShadow: '0 10px 24px rgba(16, 163, 127, 0.32)', padding: '0' }); launcher.textContent = '☰'; enableConversationOutlineLauncherDrag(launcher); launcher.addEventListener('click', event => { if (Date.now() < suppressConversationOutlineLauncherClickUntil) { event.preventDefault(); event.stopPropagation(); return; } isConversationOutlineCollapsed = false; updateConversationOutline(true); }); const header = document.createElement('div'); Object.assign(header.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', padding: '9px 10px', borderBottom: '1px solid #e5e7eb' }); const titleGroup = document.createElement('div'); Object.assign(titleGroup.style, { display: 'flex', alignItems: 'center', gap: '6px', minWidth: '0' }); const title = document.createElement('span'); title.textContent = '对话目录'; Object.assign(title.style, { fontSize: '13px', fontWeight: '700' }); const count = document.createElement('span'); count.id = 'conversation-outline-count'; Object.assign(count.style, { color: '#6b7280', fontSize: '12px', fontWeight: '600' }); const headerControls = document.createElement('div'); Object.assign(headerControls.style, { display: 'flex', alignItems: 'center', gap: '5px', flexShrink: '0' }); const exportButton = createOutlineIconButton(getOutlineIconSvg('download'), '导出'); exportButton.addEventListener('click', () => showExportDialog()); const aboutButton = createOutlineIconButton(getOutlineIconSvg('info'), '关于'); aboutButton.addEventListener('click', () => showConversationOutlineSettings('about')); const settingsButton = createOutlineIconButton(getOutlineIconSvg('settings'), '设置'); settingsButton.addEventListener('click', () => showConversationOutlineSettings('settings')); const minimizeButton = createOutlineIconButton('−', '缩小对话目录'); minimizeButton.addEventListener('click', () => { isConversationOutlineCollapsed = true; updateConversationOutline(true); }); headerControls.appendChild(exportButton); headerControls.appendChild(aboutButton); headerControls.appendChild(settingsButton); headerControls.appendChild(minimizeButton); titleGroup.appendChild(title); titleGroup.appendChild(count); header.appendChild(titleGroup); header.appendChild(headerControls); const list = document.createElement('div'); list.id = 'conversation-outline-list'; Object.assign(list.style, { maxHeight: 'calc(100vh - 176px)', overflowY: 'auto', padding: '6px' }); panel.appendChild(header); panel.appendChild(list); wrapper.appendChild(panel); wrapper.appendChild(launcher); document.body.appendChild(wrapper); updateConversationOutline(true); } function updateConversationOutline(force = false) { const wrapper = document.querySelector('#conversation-outline'); if (!wrapper) return; if (!force && Date.now() < conversationOutlineUpdatePausedUntil) return; const panel = wrapper.querySelector('#conversation-outline-panel'); const launcher = wrapper.querySelector('#conversation-outline-launcher'); const list = wrapper.querySelector('#conversation-outline-list'); const count = wrapper.querySelector('#conversation-outline-count'); if (isConversationOutlineCollapsed) { if (!isConversationOutlineLauncherDragging) { applyConversationOutlineLayout(wrapper, panel, launcher, true); } panel.style.display = 'none'; launcher.style.display = 'inline-flex'; return; } applyConversationOutlineLayout(wrapper, panel, launcher, false); panel.style.display = 'block'; launcher.style.display = 'none'; const userMessages = getChatMessages() .map((message, index) => ({message, index})) .filter(item => item.message.role === 'User' && item.message.element); count.textContent = `${userMessages.length}`; list.innerHTML = ''; if (!userMessages.length) { list.appendChild(createConversationOutlineEmptyItem()); return; } userMessages.forEach((item, userIndex) => { list.appendChild(createConversationOutlineItem(item.message, userIndex)); }); } function createConversationOutlineItem(message, userIndex) { const button = document.createElement('button'); button.type = 'button'; button.title = message.text || ''; Object.assign(button.style, { width: '100%', display: 'grid', gridTemplateColumns: '24px minmax(0, 1fr)', gap: '7px', alignItems: 'start', border: 'none', borderRadius: '6px', padding: '7px', background: 'transparent', color: '#111827', cursor: 'pointer', textAlign: 'left' }); const index = document.createElement('span'); index.textContent = String(userIndex + 1); Object.assign(index.style, { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '22px', height: '22px', borderRadius: '50%', background: '#e0f2fe', color: '#0369a1', fontSize: '11px', fontWeight: '700' }); const text = document.createElement('span'); text.textContent = getConversationOutlineText(message); Object.assign(text.style, { minWidth: '0', fontSize: '12px', lineHeight: '17px', color: '#374151', overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: '2', WebkitBoxOrient: 'vertical' }); button.addEventListener('mouseenter', () => { button.style.background = '#f3f4f6'; }); button.addEventListener('mouseleave', () => { button.style.background = 'transparent'; }); button.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); scrollToConversationMessage(message.element); }); button.appendChild(index); button.appendChild(text); return button; } function createConversationOutlineEmptyItem() { const item = document.createElement('div'); item.textContent = '暂未识别到用户发话'; Object.assign(item.style, { padding: '12px 8px', color: '#6b7280', fontSize: '12px', lineHeight: '18px' }); return item; } function createOutlineIconButton(text, label) { const button = document.createElement('button'); button.type = 'button'; button.title = label; button.setAttribute('aria-label', label); Object.assign(button.style, { width: '28px', height: '28px', border: 'none', borderRadius: '6px', background: '#f3f4f6', color: '#111827', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', lineHeight: '28px', padding: '0' }); if (text instanceof Node) { button.appendChild(text); } else { button.textContent = text; } return button; } function getOutlineIconSvg(name) { const paths = { download: [ '', '', '' ], info: [ '', '', '' ], settings: [ '', '' ] }; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '16'); svg.setAttribute('height', '16'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '2'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); svg.setAttribute('aria-hidden', 'true'); svg.innerHTML = paths[name].join(''); return svg; } function showConversationOutlineSettings(initialPanel = 'settings') { const existing = document.querySelector('#conversation-outline-settings-modal'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'conversation-outline-settings-modal'; Object.assign(overlay.style, { position: 'fixed', inset: '0', zIndex: '10003', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '18px', background: 'rgba(15, 23, 42, 0.28)', fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }); const dialog = document.createElement('div'); Object.assign(dialog.style, { width: 'min(440px, calc(100vw - 36px))', borderRadius: '8px', background: '#fff', color: '#111827', boxShadow: '0 24px 60px rgba(15, 23, 42, 0.28)', border: '1px solid rgba(15, 23, 42, 0.1)', overflow: 'hidden' }); const header = document.createElement('div'); Object.assign(header.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '13px 14px', borderBottom: '1px solid #e5e7eb' }); const title = document.createElement('div'); title.textContent = '菜单'; Object.assign(title.style, { fontSize: '15px', fontWeight: '700' }); const closeButton = createOutlineIconButton('×', '关闭菜单'); closeButton.style.width = '30px'; closeButton.style.height = '30px'; closeButton.style.lineHeight = '30px'; const body = document.createElement('div'); Object.assign(body.style, { padding: '14px', display: 'grid', gap: '12px' }); const tabs = document.createElement('div'); Object.assign(tabs.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', padding: '3px', borderRadius: '7px', background: '#f3f4f6' }); const settingsTab = createSettingsPanelTab('设置'); const aboutTab = createSettingsPanelTab('关于'); tabs.appendChild(settingsTab); tabs.appendChild(aboutTab); const settingsPanel = document.createElement('div'); Object.assign(settingsPanel.style, { display: 'grid', gap: '12px' }); const aboutPanel = document.createElement('div'); Object.assign(aboutPanel.style, { display: 'grid', gap: '10px' }); const modeLabel = document.createElement('label'); Object.assign(modeLabel.style, { display: 'grid', gap: '6px', fontSize: '13px', fontWeight: '700', color: '#374151' }); modeLabel.textContent = '目录显示方式'; const modeSelect = document.createElement('select'); Object.assign(modeSelect.style, { height: '34px', border: '1px solid #d1d5db', borderRadius: '6px', background: '#fff', color: '#111827', fontSize: '13px', padding: '0 9px' }); [ {value: OUTLINE_MODE_FLOATING, label: '悬浮目录'}, {value: OUTLINE_MODE_EMBEDDED, label: '嵌入右侧'} ].forEach(option => { const item = document.createElement('option'); item.value = option.value; item.textContent = option.label; modeSelect.appendChild(item); }); modeSelect.value = conversationOutlineMode; modeSelect.addEventListener('change', () => { conversationOutlineMode = modeSelect.value; saveConversationOutlineMode(conversationOutlineMode); updateConversationOutline(true); }); function closeSettings() { document.removeEventListener('keydown', handleKeydown); overlay.remove(); } function handleKeydown(event) { if (event.key === 'Escape') closeSettings(); } const modeHint = document.createElement('div'); modeHint.textContent = '控制右侧对话目录是浮在页面上,还是嵌入到页面右侧。'; Object.assign(modeHint.style, { color: '#6b7280', fontSize: '12px', fontWeight: '400', lineHeight: '17px' }); const siteSection = document.createElement('div'); Object.assign(siteSection.style, { display: 'grid', gap: '8px' }); const siteTitle = document.createElement('div'); siteTitle.textContent = '匹配站点'; Object.assign(siteTitle.style, { fontSize: '13px', fontWeight: '700', color: '#374151' }); const currentSiteStatus = createSettingsHint(''); const siteHint = createSettingsHint('自定义站点保存后会在这些域名或路径前缀上启用工具。每次添加一个域名或 URL,支持 *.example.com 和 example.com/c。'); const siteInputRow = document.createElement('div'); Object.assign(siteInputRow.style, { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: '8px' }); const siteInput = document.createElement('input'); siteInput.type = 'text'; siteInput.placeholder = 'pro2.ai2.bar 或 https://rawchat.cn/c'; Object.assign(siteInput.style, { minWidth: '0', height: '34px', border: '1px solid #d1d5db', borderRadius: '6px', background: '#fff', color: '#111827', fontSize: '13px', padding: '0 9px' }); const addSiteButton = createSettingsActionButton('添加'); const addCurrentSiteButton = createSettingsSecondaryButton('添加当前站点'); const siteMessage = createSettingsHint(''); const customSiteList = document.createElement('div'); Object.assign(customSiteList.style, { display: 'grid', gap: '6px' }); function updateCurrentSiteStatus() { const currentSite = getCurrentSitePattern(); currentSiteStatus.textContent = currentSite ? `当前站点:${currentSite}(${isCurrentSiteAllowed() ? '已启用' : '未启用'})` : '当前站点:无法识别'; addCurrentSiteButton.disabled = !currentSite || isCurrentSiteAllowed(); addCurrentSiteButton.style.opacity = addCurrentSiteButton.disabled ? '0.45' : '1'; addCurrentSiteButton.style.cursor = addCurrentSiteButton.disabled ? 'not-allowed' : 'pointer'; } function showSiteMessage(text, isError = false) { siteMessage.textContent = text; siteMessage.style.color = isError ? '#b91c1c' : '#6b7280'; } function renderCustomSiteList() { customSiteList.innerHTML = ''; const patterns = loadCustomSitePatterns(); if (!patterns.length) { const empty = createSettingsHint('还没有自定义站点。'); customSiteList.appendChild(empty); updateCurrentSiteStatus(); return; } patterns.forEach(pattern => { const row = document.createElement('div'); Object.assign(row.style, { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center', gap: '8px', padding: '8px 9px', border: '1px solid #e5e7eb', borderRadius: '6px', background: '#f9fafb' }); const text = document.createElement('div'); text.textContent = pattern; Object.assign(text.style, { minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#374151', fontSize: '12px', lineHeight: '18px' }); const removeButton = createSettingsSecondaryButton('移除'); removeButton.addEventListener('click', () => { removeCustomSitePattern(pattern); renderCustomSiteList(); showSiteMessage('已移除,刷新对应站点页面后生效。'); }); row.appendChild(text); row.appendChild(removeButton); customSiteList.appendChild(row); }); updateCurrentSiteStatus(); } function addSiteFromInput(input) { const result = addCustomSitePattern(input); if (!result.ok) { showSiteMessage(result.error, true); return; } siteInput.value = ''; renderCustomSiteList(); if (isCurrentSiteAllowed()) startConversationManager(); showSiteMessage(result.changed ? `已添加 ${result.pattern}。` : `${result.pattern} 已经在匹配列表中。`); } addSiteButton.addEventListener('click', () => addSiteFromInput(siteInput.value)); siteInput.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); addSiteFromInput(siteInput.value); } }); addCurrentSiteButton.addEventListener('click', () => addSiteFromInput(getCurrentSitePattern())); siteInputRow.appendChild(siteInput); siteInputRow.appendChild(addSiteButton); siteSection.appendChild(siteTitle); siteSection.appendChild(currentSiteStatus); siteSection.appendChild(siteHint); siteSection.appendChild(siteInputRow); siteSection.appendChild(addCurrentSiteButton); siteSection.appendChild(customSiteList); siteSection.appendChild(siteMessage); renderCustomSiteList(); const aboutTitle = document.createElement('div'); aboutTitle.textContent = '关于'; Object.assign(aboutTitle.style, { fontSize: '13px', fontWeight: '700', color: '#374151' }); const aboutInfo = document.createElement('div'); Object.assign(aboutInfo.style, { padding: '10px 11px', border: '1px solid #e5e7eb', borderRadius: '7px', color: '#6b7280', fontSize: '12px', lineHeight: '18px', background: '#f9fafb', display: 'grid', gap: '6px' }); const apiLink = document.createElement('a'); apiLink.href = API_SITE_URL; apiLink.target = '_blank'; apiLink.rel = 'noopener noreferrer'; apiLink.textContent = 'api站:aio.folksaying.cn'; apiLink.title = '新建页面打开 api 站'; Object.assign(apiLink.style, { color: '#2563eb', fontWeight: '700', textDecoration: 'none' }); apiLink.addEventListener('mouseenter', () => { apiLink.style.textDecoration = 'underline'; }); apiLink.addEventListener('mouseleave', () => { apiLink.style.textDecoration = 'none'; }); const feedbackLink = document.createElement('a'); feedbackLink.href = FEEDBACK_URL; feedbackLink.target = '_blank'; feedbackLink.rel = 'noopener noreferrer'; feedbackLink.textContent = '反馈地址:GitHub Issues'; feedbackLink.title = '新建页面打开反馈地址'; Object.assign(feedbackLink.style, { color: '#2563eb', fontWeight: '700', textDecoration: 'none' }); feedbackLink.addEventListener('mouseenter', () => { feedbackLink.style.textDecoration = 'underline'; }); feedbackLink.addEventListener('mouseleave', () => { feedbackLink.style.textDecoration = 'none'; }); function switchPanel(panelName) { const isAbout = panelName === 'about'; settingsPanel.style.display = isAbout ? 'none' : 'grid'; aboutPanel.style.display = isAbout ? 'grid' : 'none'; applySettingsPanelTabState(settingsTab, !isAbout); applySettingsPanelTabState(aboutTab, isAbout); } settingsTab.addEventListener('click', () => switchPanel('settings')); aboutTab.addEventListener('click', () => switchPanel('about')); modeLabel.appendChild(modeSelect); modeLabel.appendChild(modeHint); settingsPanel.appendChild(modeLabel); settingsPanel.appendChild(siteSection); aboutPanel.appendChild(aboutTitle); aboutInfo.appendChild(apiLink); aboutInfo.appendChild(feedbackLink); aboutPanel.appendChild(aboutInfo); body.appendChild(tabs); body.appendChild(settingsPanel); body.appendChild(aboutPanel); header.appendChild(title); header.appendChild(closeButton); dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); closeButton.addEventListener('click', closeSettings); overlay.addEventListener('click', event => { if (event.target === overlay) closeSettings(); }); document.addEventListener('keydown', handleKeydown); switchPanel(initialPanel === 'about' ? 'about' : 'settings'); } function createSettingsPanelTab(label) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; Object.assign(button.style, { height: '28px', border: 'none', borderRadius: '5px', background: 'transparent', color: '#4b5563', cursor: 'pointer', fontSize: '12px', fontWeight: '700' }); return button; } function applySettingsPanelTabState(button, active) { button.style.background = active ? '#fff' : 'transparent'; button.style.color = active ? '#111827' : '#4b5563'; button.style.boxShadow = active ? '0 1px 2px rgba(15, 23, 42, 0.08)' : 'none'; } function createSettingsHint(text) { const hint = document.createElement('div'); hint.textContent = text; Object.assign(hint.style, { color: '#6b7280', fontSize: '12px', fontWeight: '400', lineHeight: '17px' }); return hint; } function createSettingsActionButton(label) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; Object.assign(button.style, { minWidth: '56px', height: '34px', border: 'none', borderRadius: '6px', background: '#111827', color: '#fff', cursor: 'pointer', fontSize: '13px', fontWeight: '700', padding: '0 12px' }); return button; } function createSettingsSecondaryButton(label) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; Object.assign(button.style, { minWidth: '54px', height: '30px', border: '1px solid #d1d5db', borderRadius: '6px', background: '#fff', color: '#374151', cursor: 'pointer', fontSize: '12px', fontWeight: '700', padding: '0 10px' }); return button; } function loadConversationOutlineMode() { try { const saved = localStorage.getItem(OUTLINE_MODE_STORAGE_KEY); return saved === OUTLINE_MODE_EMBEDDED ? OUTLINE_MODE_EMBEDDED : OUTLINE_MODE_FLOATING; } catch (error) { return OUTLINE_MODE_FLOATING; } } function saveConversationOutlineMode(mode) { try { localStorage.setItem(OUTLINE_MODE_STORAGE_KEY, mode); } catch (error) { // localStorage 可能被站点策略禁用,当前页面切换仍然生效。 } } function loadConversationOutlineLauncherPosition() { const fallback = getDefaultConversationOutlineLauncherPosition(); try { const saved = JSON.parse(localStorage.getItem(OUTLINE_LAUNCHER_POSITION_STORAGE_KEY) || 'null'); if (!saved || typeof saved !== 'object') return fallback; if (!['left', 'right'].includes(saved.horizontal)) return fallback; if (!['top', 'bottom'].includes(saved.vertical)) return fallback; if (!Number.isFinite(saved.x) || !Number.isFinite(saved.y)) return fallback; return { horizontal: saved.horizontal, vertical: saved.vertical, x: Math.max(8, Math.round(saved.x)), y: Math.max(8, Math.round(saved.y)) }; } catch (error) { return fallback; } } function saveConversationOutlineLauncherPosition(position) { conversationOutlineLauncherPosition = position; try { localStorage.setItem(OUTLINE_LAUNCHER_POSITION_STORAGE_KEY, JSON.stringify(position)); } catch (error) { // localStorage 可能被站点策略禁用,当前页面拖动仍然生效。 } } function getDefaultConversationOutlineLauncherPosition() { return { horizontal: 'right', vertical: 'bottom', x: 14, y: 106 }; } function applyConversationOutlineLayout(wrapper, panel, launcher, collapsed) { const embedded = conversationOutlineMode === OUTLINE_MODE_EMBEDDED && !collapsed && window.innerWidth >= 1100; let embeddedTopOffset = 0; if (embedded) { embeddedTopOffset = getConversationOutlineTopOffset(); applyEmbeddedContentInset(); Object.assign(wrapper.style, { position: 'fixed', top: `${embeddedTopOffset}px`, right: '0', bottom: '0', left: 'auto', width: '284px', height: 'auto', zIndex: OUTLINE_EMBEDDED_Z_INDEX }); Object.assign(panel.style, { width: '284px', height: `calc(100vh - ${embeddedTopOffset}px)`, maxHeight: `calc(100vh - ${embeddedTopOffset}px)`, borderRadius: '0', borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: '1px solid #e5e7eb', boxShadow: 'none' }); } else { restoreEmbeddedContentInset(); const launcherPosition = collapsed ? getConversationOutlineLauncherPositionStyles() : null; Object.assign(wrapper.style, { position: 'fixed', top: collapsed ? launcherPosition.top : '96px', right: collapsed ? launcherPosition.right : '14px', bottom: collapsed ? launcherPosition.bottom : 'auto', left: collapsed ? launcherPosition.left : 'auto', width: collapsed ? '44px' : '260px', height: collapsed ? '44px' : 'auto', zIndex: OUTLINE_FLOATING_Z_INDEX }); Object.assign(panel.style, { width: '260px', height: 'auto', maxHeight: 'calc(100vh - 128px)', borderRadius: '8px', border: '1px solid rgba(15, 23, 42, 0.12)', borderLeft: '1px solid rgba(15, 23, 42, 0.12)', boxShadow: '0 14px 32px rgba(15, 23, 42, 0.16)' }); } const list = panel.querySelector('#conversation-outline-list'); if (list) { list.style.maxHeight = embedded ? `calc(100vh - ${embeddedTopOffset + 72}px)` : 'calc(100vh - 176px)'; } } function getConversationOutlineLauncherPositionStyles() { const position = conversationOutlineLauncherPosition || getDefaultConversationOutlineLauncherPosition(); const maxX = Math.max(window.innerWidth - 52, 8); const maxY = Math.max(window.innerHeight - 52, 8); const x = clampNumber(position.x, 8, maxX); const y = clampNumber(position.y, 8, maxY); return { top: position.vertical === 'top' ? `${y}px` : 'auto', right: position.horizontal === 'right' ? `${x}px` : 'auto', bottom: position.vertical === 'bottom' ? `${y}px` : 'auto', left: position.horizontal === 'left' ? `${x}px` : 'auto' }; } function enableConversationOutlineLauncherDrag(launcher) { let dragStart = null; launcher.addEventListener('pointerdown', event => { if (event.button !== undefined && event.button !== 0) return; if (!isConversationOutlineCollapsed) return; const wrapper = launcher.closest('#conversation-outline'); if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); dragStart = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, width: rect.width || 44, height: rect.height || 44, moved: false }; isConversationOutlineLauncherDragging = true; conversationOutlineUpdatePausedUntil = Date.now() + 1000; launcher.setPointerCapture(event.pointerId); document.body.style.userSelect = 'none'; }); launcher.addEventListener('pointermove', event => { if (!dragStart || event.pointerId !== dragStart.pointerId) return; const deltaX = event.clientX - dragStart.x; const deltaY = event.clientY - dragStart.y; if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) { dragStart.moved = true; } if (!dragStart.moved) return; event.preventDefault(); const wrapper = launcher.closest('#conversation-outline'); if (!wrapper) return; const left = clampNumber(dragStart.left + deltaX, 8, Math.max(window.innerWidth - dragStart.width - 8, 8)); const top = clampNumber(dragStart.top + deltaY, 8, Math.max(window.innerHeight - dragStart.height - 8, 8)); Object.assign(wrapper.style, { left: `${left}px`, top: `${top}px`, right: 'auto', bottom: 'auto' }); }); function finishDrag(event) { if (!dragStart || event.pointerId !== dragStart.pointerId) return; const wrapper = launcher.closest('#conversation-outline'); if (wrapper && dragStart.moved) { saveConversationOutlineLauncherPosition(getConversationOutlineLauncherPositionFromRect(wrapper.getBoundingClientRect())); suppressConversationOutlineLauncherClickUntil = Date.now() + 250; } try { launcher.releasePointerCapture(event.pointerId); } catch (error) { // 指针可能已经由浏览器释放。 } document.body.style.userSelect = ''; isConversationOutlineLauncherDragging = false; dragStart = null; updateConversationOutline(true); } launcher.addEventListener('pointerup', finishDrag); launcher.addEventListener('pointercancel', finishDrag); } function getConversationOutlineLauncherPositionFromRect(rect) { const width = rect.width || 44; const height = rect.height || 44; const left = clampNumber(rect.left, 8, Math.max(window.innerWidth - width - 8, 8)); const top = clampNumber(rect.top, 8, Math.max(window.innerHeight - height - 8, 8)); const right = Math.max(window.innerWidth - left - width, 8); const bottom = Math.max(window.innerHeight - top - height, 8); const horizontal = left <= right ? 'left' : 'right'; const vertical = top <= bottom ? 'top' : 'bottom'; return { horizontal, vertical, x: Math.round(horizontal === 'left' ? left : right), y: Math.round(vertical === 'top' ? top : bottom) }; } function clampNumber(value, min, max) { return Math.min(Math.max(value, min), max); } function getConversationOutlineTopOffset() { const candidates = Array.from(document.querySelectorAll('header, [role="banner"], nav, [class*="header"], [class*="topbar"], [class*="navbar"]')); let maxBottom = 0; candidates.forEach(element => { if (element.closest('#conversation-outline')) return; if (isConversationMessageLayoutElement(element)) return; const style = window.getComputedStyle(element); if (style.position !== 'fixed' && style.position !== 'sticky') return; const rect = element.getBoundingClientRect(); if (rect.top <= 4 && rect.height > 24 && rect.height < 120 && rect.width > window.innerWidth * 0.4) { maxBottom = Math.max(maxBottom, rect.bottom); } }); getRightTopOccupiedElements().forEach(element => { const rect = element.getBoundingClientRect(); if (rect.bottom > 24 && rect.bottom < 160) { maxBottom = Math.max(maxBottom, rect.bottom); } }); return Math.min(Math.max(Math.ceil(maxBottom), 0), 96); } function getRightTopOccupiedElements() { const elements = new Set(); const sampleX = Math.max(window.innerWidth - 142, 0); [12, 28, 48, 72, 96].forEach(y => { document.elementsFromPoint(sampleX, y).forEach(element => { const obstacle = getRightTopLayoutObstacle(element); if (!obstacle) return; elements.add(obstacle); }); }); return Array.from(elements); } function getRightTopLayoutObstacle(element) { if (!element || element === document.body || element === document.documentElement) return false; if (element.closest('#conversation-outline')) return false; if (isConversationMessageLayoutElement(element)) return false; const obstacle = findFixedOrStickyAncestor(element); if (!obstacle) return false; if (isConversationMessageLayoutElement(obstacle)) return false; const rect = obstacle.getBoundingClientRect(); if (rect.width < 16 || rect.height < 16) return false; if (rect.top > 120 || rect.bottom <= 0) return false; if (rect.right < window.innerWidth - 320) return false; const style = window.getComputedStyle(obstacle); if (style.visibility === 'hidden' || style.display === 'none' || style.pointerEvents === 'none') return false; return obstacle; } function findFixedOrStickyAncestor(element) { let current = element; while (current && current !== document.body && current !== document.documentElement) { if (current.closest && current.closest('#conversation-outline')) return null; if (isConversationMessageLayoutElement(current)) return null; const style = window.getComputedStyle(current); if (style.position === 'fixed' || style.position === 'sticky') { return current; } current = current.parentElement; } return null; } function isConversationMessageLayoutElement(element) { if (!element || !element.closest) return false; return Boolean(element.closest('[data-testid^="conversation-turn-"], [data-message-author-role]')); } function applyEmbeddedContentInset() { const target = findConversationLayoutTarget(); if (!target) return; if (outlineEmbeddedLayoutTarget !== target) { restoreEmbeddedContentInset(); outlineEmbeddedLayoutTarget = target; outlineEmbeddedOriginalStyles = { marginRight: target.style.marginRight, maxWidth: target.style.maxWidth, width: target.style.width, transition: target.style.transition }; } Object.assign(target.style, { marginRight: '300px', maxWidth: 'calc(100% - 300px)', transition: target.style.transition || 'margin-right 120ms ease, max-width 120ms ease' }); } function restoreEmbeddedContentInset() { if (!outlineEmbeddedLayoutTarget || !outlineEmbeddedOriginalStyles) return; Object.assign(outlineEmbeddedLayoutTarget.style, outlineEmbeddedOriginalStyles); outlineEmbeddedLayoutTarget = null; outlineEmbeddedOriginalStyles = null; } function findConversationLayoutTarget() { const turn = document.querySelector('[data-testid^="conversation-turn-"]'); if (turn) { const main = turn.closest('main, [role="main"]'); if (main) return main; const scrollContainer = getScrollableContainer(turn); if (scrollContainer && scrollContainer !== document.scrollingElement && scrollContainer !== document.documentElement) { return scrollContainer; } let current = turn.parentElement; while (current && current !== document.body) { const rect = current.getBoundingClientRect(); if (rect.width > 640 && rect.height > 300) return current; current = current.parentElement; } } return document.querySelector('main, [role="main"]') || document.body; } function scrollToConversationMessage(element) { if (!element || !element.getBoundingClientRect) return; conversationOutlineUpdatePausedUntil = Date.now() + 1800; setTemporaryConversationScrollMargin(element, '72px', 1600); element.scrollIntoView({behavior: 'auto', block: 'start', inline: 'nearest'}); [0, 80, 180, 360, 720].forEach(delay => { setTimeout(() => alignConversationMessageInView(element), delay); }); } function setTemporaryConversationScrollMargin(element, value, duration) { if (conversationOutlineScrollMarginElement && conversationOutlineScrollMarginElement !== element) { conversationOutlineScrollMarginElement.style.scrollMarginTop = conversationOutlineScrollMarginValue; } if (conversationOutlineScrollMarginElement !== element) { conversationOutlineScrollMarginElement = element; conversationOutlineScrollMarginValue = element.style.scrollMarginTop; } element.style.scrollMarginTop = value; clearTimeout(conversationOutlineScrollMarginTimer); conversationOutlineScrollMarginTimer = setTimeout(() => { if (conversationOutlineScrollMarginElement) { conversationOutlineScrollMarginElement.style.scrollMarginTop = conversationOutlineScrollMarginValue; } conversationOutlineScrollMarginElement = null; conversationOutlineScrollMarginValue = ''; conversationOutlineScrollMarginTimer = null; }, duration); } function alignConversationMessageInView(element) { if (!element || !element.isConnected || !element.getBoundingClientRect) return; const rect = element.getBoundingClientRect(); const targetTop = 72; if (Math.abs(rect.top - targetTop) <= 4) return; const scroller = getScrollableContainer(element); const scrollerRect = scroller === document.scrollingElement ? {top: 0} : scroller.getBoundingClientRect(); const nextTop = scroller.scrollTop + rect.top - scrollerRect.top - targetTop; if (typeof scroller.scrollTo === 'function') { scroller.scrollTo({top: nextTop, behavior: 'auto'}); } else { scroller.scrollTop = nextTop; } } function getScrollableContainer(element) { let current = element.parentElement; while (current && current !== document.body) { const style = window.getComputedStyle(current); if (/(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight) { return current; } current = current.parentElement; } return document.scrollingElement || document.documentElement; } function getConversationOutlineText(message) { return (message.text || message.markdown || '') .replace(/\s+/g, ' ') .trim() .slice(0, 80) || '(空消息)'; } function showExportDialog(defaultAction = 'markdown') { let existing = document.querySelector('#export-preview-modal'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'export-preview-modal'; Object.assign(overlay.style, { position: 'fixed', inset: '0', zIndex: '10002', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '18px', background: 'rgba(15, 23, 42, 0.28)' }); const dialog = document.createElement('div'); Object.assign(dialog.style, { width: 'min(620px, calc(100vw - 36px))', borderRadius: '8px', background: '#fff', color: '#111827', boxShadow: '0 24px 60px rgba(15, 23, 42, 0.28)', border: '1px solid rgba(15, 23, 42, 0.1)', overflow: 'hidden', fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }); const header = document.createElement('div'); Object.assign(header.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid #e5e7eb' }); const title = document.createElement('div'); title.textContent = '导出'; Object.assign(title.style, { fontSize: '16px', fontWeight: '700' }); const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.textContent = '×'; closeButton.setAttribute('aria-label', '关闭导出'); Object.assign(closeButton.style, { width: '30px', height: '30px', border: 'none', borderRadius: '6px', background: '#f3f4f6', color: '#111827', cursor: 'pointer', fontSize: '20px', lineHeight: '30px', padding: '0' }); header.appendChild(title); header.appendChild(closeButton); const body = document.createElement('div'); Object.assign(body.style, { padding: '16px', display: 'grid', gap: '14px' }); const rangeLabel = document.createElement('label'); rangeLabel.textContent = '导出范围'; Object.assign(rangeLabel.style, { display: 'grid', gap: '6px', fontSize: '13px', fontWeight: '600' }); const rangeSelect = document.createElement('select'); Object.assign(rangeSelect.style, { height: '36px', borderRadius: '6px', border: '1px solid #d1d5db', padding: '0 10px', fontSize: '14px', background: '#fff' }); getExportRangeOptions().forEach(option => { const item = document.createElement('option'); item.value = option.value; item.textContent = option.label; rangeSelect.appendChild(item); }); rangeLabel.appendChild(rangeSelect); const selectionTools = document.createElement('div'); Object.assign(selectionTools.style, { display: 'none', gap: '8px', flexWrap: 'wrap' }); const selectAllButton = createSecondaryButton('全选'); const clearSelectionButton = createSecondaryButton('清空'); const invertSelectionButton = createSecondaryButton('反选'); [selectAllButton, clearSelectionButton, invertSelectionButton].forEach(button => selectionTools.appendChild(button)); const messageList = document.createElement('div'); Object.assign(messageList.style, { display: 'none', maxHeight: '260px', overflow: 'auto', border: '1px solid #e5e7eb', borderRadius: '6px', background: '#fff' }); const statsGrid = document.createElement('div'); Object.assign(statsGrid.style, { display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: '8px' }); const filePreview = document.createElement('div'); Object.assign(filePreview.style, { minHeight: '34px', padding: '8px 10px', borderRadius: '6px', background: '#f9fafb', border: '1px solid #e5e7eb', color: '#374151', fontSize: '12px', lineHeight: '18px', wordBreak: 'break-all' }); body.appendChild(rangeLabel); body.appendChild(selectionTools); body.appendChild(messageList); body.appendChild(statsGrid); body.appendChild(filePreview); const footer = document.createElement('div'); Object.assign(footer.style, { display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end', padding: '14px 16px', borderTop: '1px solid #e5e7eb', background: '#f9fafb' }); const markdownButton = createPreviewActionButton('下载 Markdown', '#f59e0b', () => { let options = getCurrentExportOptions(); closePreview(); exportChatAsMarkdown(options); }); const htmlButton = createPreviewActionButton('下载 HTML', '#0ea5e9', () => { let options = getCurrentExportOptions(); closePreview(); exportChatAsHTML(options); }); const copyButton = createPreviewActionButton('复制 Markdown', '#6366f1', async () => { let options = getCurrentExportOptions(); closePreview(); await copyChatAsMarkdown(options); }); [copyButton, markdownButton, htmlButton].forEach(button => footer.appendChild(button)); if (defaultAction === 'html') htmlButton.focus(); if (defaultAction === 'copy') copyButton.focus(); dialog.appendChild(header); dialog.appendChild(body); dialog.appendChild(footer); overlay.appendChild(dialog); document.body.appendChild(overlay); function closePreview() { document.removeEventListener('keydown', handleKeydown); overlay.remove(); } function handleKeydown(event) { if (event.key === 'Escape') closePreview(); } const allMessages = getChatMessages(); const selectedIndexes = new Set(allMessages.map((message, index) => index)); renderMessageSelectionList(); function getCurrentExportOptions() { return { range: rangeSelect.value, selectedIndexes: rangeSelect.value === 'custom' ? Array.from(selectedIndexes) : [] }; } function getCurrentMessages() { const options = getCurrentExportOptions(); return filterChatMessagesByRange(allMessages, options.range, options.selectedIndexes); } function updatePreview() { const isCustom = rangeSelect.value === 'custom'; selectionTools.style.display = isCustom ? 'flex' : 'none'; messageList.style.display = isCustom ? 'block' : 'none'; const messages = getCurrentMessages(); const stats = getExportStats(messages); statsGrid.innerHTML = ''; [ ['消息', stats.messages], ['用户', stats.users], ['助手', stats.assistants], ['代码块', stats.codeBlocks], ['表格', stats.tables], ['公式', stats.math] ].forEach(([label, value]) => statsGrid.appendChild(createPreviewStat(label, value))); let suffix = defaultAction === 'html' ? 'html' : 'md'; filePreview.textContent = messages.length ? `文件名预览:${getExportBaseName()}.${suffix}` : '当前范围没有可导出的消息'; [copyButton, markdownButton, htmlButton].forEach(button => { button.disabled = messages.length === 0; button.style.opacity = messages.length ? '1' : '0.45'; button.style.cursor = messages.length ? 'pointer' : 'not-allowed'; }); } selectAllButton.addEventListener('click', () => { allMessages.forEach((message, index) => selectedIndexes.add(index)); renderMessageSelectionList(); updatePreview(); }); clearSelectionButton.addEventListener('click', () => { selectedIndexes.clear(); renderMessageSelectionList(); updatePreview(); }); invertSelectionButton.addEventListener('click', () => { allMessages.forEach((message, index) => { if (selectedIndexes.has(index)) { selectedIndexes.delete(index); } else { selectedIndexes.add(index); } }); renderMessageSelectionList(); updatePreview(); }); function renderMessageSelectionList() { messageList.innerHTML = ''; if (!allMessages.length) { const empty = document.createElement('div'); empty.textContent = '没有识别到可选择的消息'; Object.assign(empty.style, { padding: '12px', color: '#6b7280', fontSize: '13px' }); messageList.appendChild(empty); return; } allMessages.forEach((message, index) => { messageList.appendChild(createMessageSelectionItem(message, index, selectedIndexes, updatePreview)); }); } closeButton.addEventListener('click', closePreview); overlay.addEventListener('click', event => { if (event.target === overlay) closePreview(); }); rangeSelect.addEventListener('change', updatePreview); document.addEventListener('keydown', handleKeydown); updatePreview(); } function createPreviewActionButton(text, bgColor, onClick) { const button = document.createElement('button'); button.type = 'button'; button.textContent = text; Object.assign(button.style, { minHeight: '34px', border: 'none', borderRadius: '6px', padding: '0 12px', background: bgColor, color: '#fff', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }); button.addEventListener('click', onClick); return button; } function createSecondaryButton(text) { const button = document.createElement('button'); button.type = 'button'; button.textContent = text; Object.assign(button.style, { minHeight: '30px', border: '1px solid #d1d5db', borderRadius: '6px', padding: '0 10px', background: '#fff', color: '#374151', fontSize: '12px', fontWeight: '600', cursor: 'pointer' }); return button; } function createMessageSelectionItem(message, index, selectedIndexes, onChange) { const label = document.createElement('label'); Object.assign(label.style, { display: 'grid', gridTemplateColumns: '22px minmax(0, 1fr)', gap: '8px', alignItems: 'start', padding: '9px 10px', borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = selectedIndexes.has(index); checkbox.style.marginTop = '3px'; const content = document.createElement('div'); content.style.minWidth = '0'; const title = document.createElement('div'); title.textContent = `${index + 1}. ${message.role}`; Object.assign(title.style, { fontSize: '12px', fontWeight: '700', color: message.role === 'Assistant' ? '#047857' : '#1d4ed8', marginBottom: '3px' }); const preview = document.createElement('div'); preview.textContent = getMessageSelectionPreview(message); Object.assign(preview.style, { fontSize: '12px', color: '#4b5563', lineHeight: '17px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }); checkbox.addEventListener('change', () => { if (checkbox.checked) { selectedIndexes.add(index); } else { selectedIndexes.delete(index); } onChange(); }); content.appendChild(title); content.appendChild(preview); label.appendChild(checkbox); label.appendChild(content); return label; } function getMessageSelectionPreview(message) { return (message.text || message.markdown || '') .replace(/\s+/g, ' ') .trim() .slice(0, 120) || '(空消息)'; } function createPreviewStat(label, value) { const item = document.createElement('div'); Object.assign(item.style, { padding: '8px', borderRadius: '6px', background: '#f3f4f6', textAlign: 'center', minWidth: '0' }); const number = document.createElement('div'); number.textContent = value; Object.assign(number.style, { fontSize: '17px', fontWeight: '700', lineHeight: '20px' }); const caption = document.createElement('div'); caption.textContent = label; Object.assign(caption.style, { marginTop: '2px', fontSize: '11px', color: '#6b7280' }); item.appendChild(number); item.appendChild(caption); return item; } function getExportRangeOptions() { return [ {value: 'all', label: '全部对话'}, {value: 'custom', label: '自定义选择'}, {value: 'last-turn', label: '最后一轮'}, {value: 'assistant-only', label: '只导出助手回答'}, {value: 'from-visible', label: '从当前可见位置开始'} ]; } function getExportStats(messages) { return messages.reduce((stats, message) => { stats.messages += 1; if (message.role === 'User') stats.users += 1; if (message.role === 'Assistant') stats.assistants += 1; stats.codeBlocks += countMessageElements(message, 'pre'); stats.tables += countMessageElements(message, 'table'); stats.math += countMessageElements(message, '.katex, .katex-display, math, mjx-container'); return stats; }, { messages: 0, users: 0, assistants: 0, codeBlocks: 0, tables: 0, math: 0 }); } function countMessageElements(message, selector) { if (message.element && message.element.querySelectorAll) { return message.element.querySelectorAll(selector).length; } return 0; } async function exportChatAsMarkdown(options = {}) { try { let allElements = getFilteredChatMessages(options.range, options.selectedIndexes); if (!allElements.length) { alert('未找到可导出的聊天内容,请等待页面加载完成后再试。'); return; } download(buildMarkdownContent(allElements), `${getExportBaseName()}.md`, 'text/markdown'); } catch (error) { console.error("导出为 Markdown 时出错:", error); } } async function exportChatAsHTML(options = {}) { try { let allElements = getFilteredChatMessages(options.range, options.selectedIndexes); if (!allElements.length) { alert('未找到可导出的聊天内容,请等待页面加载完成后再试。'); return; } download(buildHTMLContent(allElements), `${getExportBaseName()}.html`, 'text/html'); } catch (error) { console.error("导出为 HTML 时出错:", error); } } async function copyChatAsMarkdown(options = {}) { try { let allElements = getFilteredChatMessages(options.range, options.selectedIndexes); if (!allElements.length) { alert('未找到可复制的聊天内容,请等待页面加载完成后再试。'); return; } await copyTextToClipboard(buildMarkdownContent(allElements)); alert('Markdown 已复制到剪贴板。'); } catch (error) { console.error("复制 Markdown 时出错:", error); alert('复制失败,浏览器可能限制了剪贴板权限。'); } } async function copyTextToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return; } catch (error) { // 继续使用 textarea 兜底,兼容 Tampermonkey 权限差异。 } } const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'readonly'); Object.assign(textarea.style, { position: 'fixed', left: '-9999px', top: '0', opacity: '0' }); document.body.appendChild(textarea); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); let ok = document.execCommand('copy'); textarea.remove(); if (!ok) throw new Error('execCommand copy failed'); } function buildMarkdownContent(messages) { let markdownContent = "# ChatGPT 对话记录\n\n"; messages.forEach((message) => { markdownContent += `## ${message.role}\n\n${formatMessageMarkdownForExport(message)}\n\n`; }); return markdownContent; } function buildHTMLContent(messages) { let htmlContent = getHTMLDocumentStart(); messages.forEach((message) => { let roleClass = message.role.toLowerCase(); let messageHTML = message.html ? shiftHTMLHeadingLevels(message.html, 2) : `

${escapeHTML(message.text)}

`; htmlContent += `

${escapeHTML(message.role)}

${messageHTML}
`; }); return `${htmlContent}`; } function formatMessageMarkdownForExport(message) { return shiftMarkdownHeadingLevels(cleanMarkdown(message.markdown || message.text || ''), 2); } function shiftMarkdownHeadingLevels(markdown, offset) { let fence = null; return String(markdown || '') .replace(/\r/g, '') .split('\n') .map(line => { let fenceMatch = line.match(/^(\s{0,3})(`{3,}|~{3,})/); if (fenceMatch) { let marker = fenceMatch[2][0]; let length = fenceMatch[2].length; if (!fence) { fence = {marker, length}; } else if (marker === fence.marker && length >= fence.length) { fence = null; } return line; } if (fence) return line; return line.replace(/^(\s{0,3})(#{1,6})(?!#)(\s+)(.*)$/, (match, indent, hashes, spacing, text) => { let level = Math.min(6, hashes.length + offset); return `${indent}${'#'.repeat(level)}${spacing}${text}`.trimEnd(); }); }) .join('\n'); } function shiftHTMLHeadingLevels(html, offset) { let template = document.createElement('template'); template.innerHTML = html || ''; template.content.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(heading => { let level = Number(heading.tagName.slice(1)); let nextLevel = Math.min(6, level + offset); if (nextLevel === level) return; let replacement = document.createElement(`h${nextLevel}`); Array.from(heading.attributes).forEach(attribute => { replacement.setAttribute(attribute.name, attribute.value); }); while (heading.firstChild) { replacement.appendChild(heading.firstChild); } heading.replaceWith(replacement); }); return template.innerHTML; } function getFilteredChatMessages(range = 'all', selectedIndexes = []) { return filterChatMessagesByRange(getChatMessages(), range || 'all', selectedIndexes); } function filterChatMessagesByRange(messages, range, selectedIndexes = []) { if (range === 'custom') { const selected = new Set(selectedIndexes); return messages.filter((message, index) => selected.has(index)); } if (range === 'assistant-only') { return messages.filter(message => message.role === 'Assistant'); } if (range === 'last-turn') { let lastUserIndex = messages.map(message => message.role).lastIndexOf('User'); return lastUserIndex >= 0 ? messages.slice(lastUserIndex) : messages.slice(-1); } if (range === 'from-visible') { let firstVisibleIndex = messages.findIndex(message => { if (!message.element || !message.element.getBoundingClientRect) return false; return message.element.getBoundingClientRect().bottom >= 0; }); return firstVisibleIndex >= 0 ? messages.slice(firstVisibleIndex) : messages; } return messages; } function getChatMessages() { let turnElements = Array.from( document.querySelectorAll('[data-testid^="conversation-turn-"]') ).filter(el => getMessageText(el).length > 0); if (turnElements.length > 0) { return turnElements.map((turn, index) => { let messageElements = Array.from( turn.querySelectorAll('[data-message-author-role]') ).filter(el => getMessageText(el).length > 0); if (messageElements.length > 0) { return { role: getMessageRole(messageElements[0], index), text: messageElements.map(getMessageText).join('\n\n'), markdown: messageElements.map(getMessageMarkdown).filter(Boolean).join('\n\n'), html: messageElements.map(getMessageHTML).filter(Boolean).join('\n\n'), element: turn }; } return { role: index % 2 === 0 ? 'User' : 'Assistant', text: getMessageText(turn), markdown: getMessageMarkdown(turn), html: getMessageHTML(turn), element: turn }; }).filter(message => message.text.length > 0); } let roleElements = Array.from( document.querySelectorAll('[data-message-author-role]') ).filter(el => getMessageText(el).length > 0); if (roleElements.length > 0) { return roleElements.map((element, index) => ({ role: getMessageRole(element, index), text: getMessageText(element), markdown: getMessageMarkdown(element), html: getMessageHTML(element), element })); } // 旧版 ChatGPT/镜像站兜底:用户消息通常仍是 whitespace-pre-wrap。 return Array.from( document.querySelectorAll('div.whitespace-pre-wrap') ).filter(el => getMessageText(el).length > 0).map((element, index) => ({ role: getMessageRole(element, index), text: getMessageText(element), markdown: getMessageMarkdown(element), html: getMessageHTML(element), element })); } function getMessageRole(element, index) { let role = (element.getAttribute('data-message-author-role') || '').toLowerCase(); if (role === 'user') return 'User'; if (role === 'assistant') return 'Assistant'; if (role === 'system') return 'System'; return index % 2 === 0 ? 'User' : 'Assistant'; } function getMessageText(element) { return (element.innerText || element.textContent || '') .replace(/\r/g, '') .replace(/\n{3,}/g, '\n\n') .trim(); } function getMessageMarkdown(element) { let content = findMessageContentElement(element); return cleanMarkdown(renderMarkdownNode(content)).trim() || getMessageText(element); } function getMessageHTML(element) { let content = findMessageContentElement(element); let clone = content.cloneNode(true); clone.querySelectorAll('script, style, button, svg, textarea, input, select, #conversation-outline, #conversation-outline-settings-modal, #tooltip, [aria-hidden="true"]').forEach(el => el.remove()); clone.querySelectorAll('[class], [style], [data-start], [data-end], [data-section-id], [data-col-size], [data-state], [data-testid]').forEach(el => { if (el.tagName === 'A' && el.hasAttribute('href')) { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); } el.removeAttribute('class'); el.removeAttribute('style'); el.removeAttribute('data-start'); el.removeAttribute('data-end'); el.removeAttribute('data-section-id'); el.removeAttribute('data-col-size'); el.removeAttribute('data-state'); el.removeAttribute('data-testid'); }); return clone.innerHTML.trim() || `

${escapeHTML(getMessageText(element))}

`; } function findMessageContentElement(element) { let markdown = element.querySelector('.markdown.prose, .markdown, .prose'); if (markdown) return markdown; let userBubble = element.querySelector('div.whitespace-pre-wrap, [class*="whitespace-pre-wrap"]'); if (userBubble) return userBubble; return element; } function renderMarkdownNode(node, context = {}) { if (!node) return ''; if (node.nodeType === Node.TEXT_NODE) { return node.nodeValue || ''; } if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) { return ''; } if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { return renderMarkdownChildren(node, context); } let element = node; let tag = element.tagName.toLowerCase(); let math = extractMathMarkdown(element); if (math) return math; if (shouldSkipMarkdownElement(element)) return ''; if (/^h[1-6]$/.test(tag)) { let level = Number(tag.slice(1)); return `${'#'.repeat(level)} ${renderMarkdownInlineChildren(element).trim()}\n\n`; } if (tag === 'p') { return `${renderMarkdownInlineChildren(element).trim()}\n\n`; } if (tag === 'br') { return ' \n'; } if (tag === 'hr') { return '\n---\n\n'; } if (tag === 'strong' || tag === 'b') { return wrapMarkdownInline(renderMarkdownInlineChildren(element), '**'); } if (tag === 'em' || tag === 'i') { return wrapMarkdownInline(renderMarkdownInlineChildren(element), '*'); } if (tag === 's' || tag === 'del') { return wrapMarkdownInline(renderMarkdownInlineChildren(element), '~~'); } if (tag === 'code') { if (element.closest('pre')) return element.textContent || ''; return inlineCode(element.textContent || ''); } if (tag === 'pre') { let code = element.querySelector('code'); let language = getCodeLanguage(code || element); let text = trimCodeBlockBlankEdges(getCodeBlockText(element, code)); return `\n\`\`\`${language}\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`; } if (tag === 'a') { let text = renderMarkdownInlineChildren(element).trim() || element.getAttribute('href') || ''; let href = element.getAttribute('href') || ''; return href ? `[${escapeMarkdownLinkText(text)}](${href})` : text; } if (tag === 'img') { let alt = element.getAttribute('alt') || ''; let src = element.getAttribute('src') || ''; return src ? `![${escapeMarkdownLinkText(alt)}](${src})` : alt; } if (tag === 'blockquote') { let text = cleanMarkdown(renderMarkdownChildren(element, context)).trim(); return text ? `${text.split('\n').map(line => `> ${line}`).join('\n')}\n\n` : ''; } if (tag === 'ul' || tag === 'ol') { return `${renderMarkdownList(element, tag === 'ol', context.listDepth || 0)}\n`; } if (tag === 'table') { return `${renderMarkdownTable(element)}\n\n`; } if (tag === 'thead' || tag === 'tbody' || tag === 'tr') { return renderMarkdownChildren(element, context); } if (tag === 'th' || tag === 'td') { return renderMarkdownInlineChildren(element).trim(); } if (isBlockMarkdownElement(element)) { let rendered = renderMarkdownChildren(element, context).trim(); return rendered ? `${rendered}\n\n` : ''; } return renderMarkdownInlineChildren(element); } function renderMarkdownChildren(element, context = {}) { return Array.from(element.childNodes).map(child => renderMarkdownNode(child, context)).join(''); } function renderMarkdownInlineChildren(element) { return Array.from(element.childNodes).map(child => renderMarkdownNode(child, {inline: true})).join(''); } function renderMarkdownList(listElement, ordered, depth) { let items = Array.from(listElement.children).filter(child => child.tagName && child.tagName.toLowerCase() === 'li'); return items.map((item, index) => { let marker = ordered ? `${index + 1}. ` : '- '; let indent = ' '.repeat(depth); let nested = []; let parts = []; Array.from(item.childNodes).forEach(child => { if (child.nodeType === Node.ELEMENT_NODE && /^(ul|ol)$/i.test(child.tagName)) { nested.push(renderMarkdownList(child, child.tagName.toLowerCase() === 'ol', depth + 1).trimEnd()); } else if (child.nodeType === Node.ELEMENT_NODE && child.tagName.toLowerCase() === 'p') { parts.push(renderMarkdownInlineChildren(child).trim()); } else { parts.push(renderMarkdownNode(child, {inline: true, listDepth: depth}).trim()); } }); let text = cleanMarkdown(parts.filter(Boolean).join(' ')).replace(/\n{2,}/g, '\n').trim(); let line = `${indent}${marker}${text}`.replace(/\n/g, `\n${indent} `); return [line, ...nested].filter(Boolean).join('\n'); }).join('\n'); } function renderMarkdownTable(table) { let rows = Array.from(table.querySelectorAll('tr')).map(row => { let cells = Array.from(row.children).filter(cell => /^(th|td)$/i.test(cell.tagName)); return cells.map(cell => tableCellMarkdown(cell)); }).filter(row => row.length > 0); if (!rows.length) return ''; let columnCount = Math.max(...rows.map(row => row.length)); let normalizedRows = rows.map(row => { while (row.length < columnCount) row.push(''); return row; }); let separator = Array.from({length: columnCount}, () => '---'); return [ `| ${normalizedRows[0].join(' | ')} |`, `| ${separator.join(' | ')} |`, ...normalizedRows.slice(1).map(row => `| ${row.join(' | ')} |`) ].join('\n'); } function tableCellMarkdown(cell) { return cleanMarkdown(renderMarkdownInlineChildren(cell)) .replace(/\n+/g, '
') .replace(/\|/g, '\\|') .trim(); } function shouldSkipMarkdownElement(element) { let tag = element.tagName.toLowerCase(); if (['script', 'style', 'button', 'textarea', 'input', 'select', 'svg'].includes(tag)) return true; if (element.id === 'conversation-outline' || element.id === 'conversation-outline-settings-modal' || element.id === 'tooltip') return true; if (element.closest('#conversation-outline')) return true; if (element.closest('#conversation-outline-settings-modal')) return true; if (element.getAttribute('aria-hidden') === 'true' && !isMathElement(element)) return true; return false; } function isBlockMarkdownElement(element) { return /^(address|article|aside|div|footer|header|main|nav|section)$/i.test(element.tagName); } function isMathElement(element) { let tag = element.tagName.toLowerCase(); let className = String(element.className || ''); return tag === 'math' || tag === 'mjx-container' || className.includes('katex') || className.includes('math'); } function extractMathMarkdown(element) { let tag = element.tagName.toLowerCase(); let className = String(element.className || ''); let tex = ''; if (tag === 'script' && /^math\/tex/.test(element.type || '')) { tex = element.textContent || ''; } else if (isMathElement(element)) { let annotation = element.querySelector('annotation[encoding*="tex" i], annotation[encoding="application/x-tex" i]'); tex = annotation ? annotation.textContent : ''; tex = tex || element.getAttribute('data-tex') || element.getAttribute('aria-label') || ''; } tex = tex.trim(); if (!tex) return ''; let isBlock = className.includes('katex-display') || element.getAttribute('display') === 'true'; return isBlock ? `\n$$\n${tex}\n$$\n\n` : `$${tex}$`; } function wrapMarkdownInline(text, marker) { text = text.trim(); return text ? `${marker}${text}${marker}` : ''; } function inlineCode(text) { let ticks = text.includes('`') ? '``' : '`'; return `${ticks}${text}${ticks}`; } function getCodeBlockText(preElement, codeElement) { let root = codeElement || preElement; let lineElements = Array.from(root.querySelectorAll('[data-line], .line, .hljs-line, [class*="code-line"]')) .filter(line => !line.querySelector('[data-line], .line, .hljs-line, [class*="code-line"]')); if (lineElements.length > 0) { return lineElements.map(line => line.textContent || '').join('\n'); } let blockText = root.innerText || root.textContent || ''; if (blockText.includes('\n')) return blockText; let html = root.innerHTML || ''; if (//i.test(html) || /<\/(div|p|li)>/i.test(html)) { let clone = root.cloneNode(true); clone.querySelectorAll('br').forEach(br => br.replaceWith('\n')); clone.querySelectorAll('div, p, li').forEach(block => { if (block.nextSibling) block.appendChild(document.createTextNode('\n')); }); blockText = clone.textContent || ''; } return blockText; } function trimCodeBlockBlankEdges(text) { return String(text || '') .replace(/\r/g, '') .replace(/^(?:[ \t]*\n)+/, '') .replace(/\n[ \t]*$/, ''); } function getCodeLanguage(element) { let className = String(element.className || ''); let match = className.match(/language-([a-z0-9_-]+)/i); return match ? match[1] : ''; } function escapeMarkdownLinkText(text) { return String(text).replace(/([\\\]\[])/g, '\\$1'); } function cleanMarkdown(markdown) { return String(markdown) .replace(/\r/g, '') .replace(/[ \t]+\n/g, '\n') .replace(/\n{4,}/g, '\n\n\n') .trim(); } function getExportBaseName() { let title = (document.title || '').trim() || getFirstUserMessage() || 'chat-export'; title = title .replace(/\s+/g, ' ') .replace(/[\\/:*?"<>|]/g, '_') .replace(/[\u0000-\u001f]/g, '') .replace(/^\.+/, '') .trim(); if (!title) title = 'chat-export'; return `${title.slice(0, 80)} (${getExportTimestamp()})`; } function getExportTimestamp(date = new Date()) { const pad = value => String(value).padStart(2, '0'); return [ date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate()) ].join('-') + ` ${pad(date.getHours())}-${pad(date.getMinutes())}`; } function getFirstUserMessage() { let userMessage = document.querySelector('[data-message-author-role="user"]'); return userMessage ? getMessageText(userMessage).slice(0, 80) : ''; } function getHTMLDocumentStart() { return ` Chat Export

ChatGPT 对话记录

`; } function escapeHTML(text) { return String(text).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); } function download(data, filename, type) { const blob = new Blob([data], {type: type}); const a = document.createElement('a'); const url = URL.createObjectURL(blob); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } let outlineUpdateTimer = null; let conversationManagerStarted = false; let conversationOutlineObserver = null; registerSiteAccessMenuCommands(); if (isCurrentSiteAllowed()) startConversationManager(); function startConversationManager() { if (conversationManagerStarted) return; conversationManagerStarted = true; const boot = () => { createConversationOutline(); startConversationObserver(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, {once: true}); } else { boot(); } window.addEventListener('resize', () => { clearTimeout(outlineUpdateTimer); outlineUpdateTimer = setTimeout(updateConversationOutline, 200); }); } function startConversationObserver() { if (conversationOutlineObserver || !document.body) return; conversationOutlineObserver = new MutationObserver(() => { if (!isCurrentSiteAllowed()) return; if (!document.querySelector('#conversation-outline')) { createConversationOutline(); } clearTimeout(outlineUpdateTimer); outlineUpdateTimer = setTimeout(updateConversationOutline, 300); }); conversationOutlineObserver.observe(document.body, {childList: true, subtree: true}); } })();