// ==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}