勾选你想分享的消息,生成后可复制或保存 PNG。
内容在本机处理,不上传对话。
`;
const $ = id => ui.getElementById(id);
let records = [], selected = new Set(), outputs = [], busy = false, stop = false, sourceURL = '';
let lastFocus = null;
const nodeKeys = new WeakMap();
const nodeRoles = new WeakMap();
let snapshots = new WeakMap();
let nodeCounter = 0;
let toastTimer;
function toast(text, retry = false, duration = 4500) {
clearTimeout(toastTimer); $('toast').replaceChildren(document.createTextNode(text)); $('toast').style.display = 'block';
if (retry) {
const copy = document.createElement('button'); copy.textContent = '重试复制';
copy.onclick = async () => {
try { await navigator.clipboard.write([new ClipboardItem({'image/png':outputs[0].blob})]); toast('图片已复制,可以直接粘贴。'); clearOutputs(); }
catch { toast('复制失败,可保存 PNG 后发送。', true, 0); }
};
const saveButton = document.createElement('button'); saveButton.textContent = '保存 PNG'; saveButton.onclick = () => save(0);
$('toast').append(copy, saveButton);
}
if (duration) toastTimer = setTimeout(() => { $('toast').style.display = 'none'; }, duration);
}
function status(text, error = false) { $('status').textContent = text; $('status').classList.toggle('error', error); }
function setBusy(value, scanning = false) {
busy = value;
for (const id of ['generate','reload','all','none','ai','title','theme','length']) $(id).disabled = value;
document.querySelectorAll('[data-gpt-share-single]').forEach(button => { button.disabled = value; });
$('cancel').style.display = value && scanning ? 'block' : 'none';
}
function messages() {
const main = document.querySelector('main') || document.body;
const found = [...main.querySelectorAll('[data-message-author-role="user"], [data-message-author-role="assistant"]')]
.filter(el => !el.parentElement?.closest('[data-message-author-role]') && !host.contains(el));
for (const node of found) nodeRoles.set(node, node.getAttribute('data-message-author-role'));
const unitSelector = '[data-content-search-unit-key], [data-chatgpt-search-unit-key]';
const add = (node, role) => {
if (!node || host.contains(node) || found.some(existing => existing === node || existing.contains(node) || node.contains(existing))) return;
nodeRoles.set(node, role); found.push(node);
};
// Current web renderer: user bubbles and assistant headings live in search units.
for (const bubble of main.querySelectorAll('[data-user-message-bubble]')) add(bubble.closest(unitSelector) || bubble, 'user');
for (const marker of main.querySelectorAll('[data-conversation-role="assistant"]')) add(marker.closest(unitSelector) || marker.parentElement, 'assistant');
// Virtualization can leave a partial message without its heading in the DOM.
for (const unit of main.querySelectorAll(unitSelector)) {
const label = unit.getAttribute('data-content-search-unit-key') || unit.getAttribute('data-chatgpt-search-unit-key') || '';
const role = /:user$/.test(label) ? 'user' : /:assistant$/.test(label) ? 'assistant' : null;
if (role && unit.querySelector('[data-user-message-bubble], [data-markdown-text-style], [class*="MarkdownRoot-"]')) add(unit, role);
}
return found.sort((a,b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
}
const roleOf = node => nodeRoles.get(node) || node.getAttribute('data-message-author-role');
function keyOf(node) {
const turn = node.closest('[data-testid^="conversation-turn-"]');
const role = roleOf(node);
const unit = node.closest('[data-content-search-unit-key], [data-chatgpt-search-unit-key]');
const content = node.matches('[data-chatgpt-selection-message-id]') ? node : node.querySelector('[data-chatgpt-selection-message-id]');
const userTurn = role === 'user' ? node.closest('[data-turn-key]')?.getAttribute('data-turn-key') : null;
const searchIds = node.closest('[data-chatgpt-search-message-ids]')?.getAttribute('data-chatgpt-search-message-ids');
const id = node.getAttribute('data-message-id') || content?.getAttribute('data-chatgpt-selection-message-id') || userTurn || searchIds?.trim().split(/\s+/)[0];
if (id) return `${role}:${id}`;
if (turn) return `${turn.getAttribute('data-testid')}:${role}:${[...turn.querySelectorAll('[data-message-author-role]')].indexOf(node)}`;
if (unit) return `${role}:${unit.getAttribute('data-content-search-unit-key') || unit.getAttribute('data-chatgpt-search-unit-key')}`;
if (!nodeKeys.has(node)) nodeKeys.set(node, `node:${++nodeCounter}`);
return nodeKeys.get(node);
}
const mathProps = ['display','position','top','left','right','bottom','width','height','min-width','vertical-align','font-family','font-size','font-style','font-weight','line-height','letter-spacing','margin-left','margin-right','margin-top','margin-bottom','padding-left','padding-right','border-top-width','border-top-style','border-bottom-width','border-bottom-style','transform','transform-origin','text-align','white-space'];
const citationSelector = '[data-testid*="citation"], [data-testid*="source-pill"], [data-testid*="source-chip"], [data-citation-id], [data-type="citation"], .citation-link, [class*="Citation"], [class*="citation"]';
function decorativeImage(img) {
const url = img.currentSrc || img.src || '';
if (/\/(?:favicon(?:s)?(?:[/.?]|$)|s2\/favicons)|[?&](?:isFavicon|favicon)=/i.test(url)) return true;
const rect = img.getBoundingClientRect();
// A citation container can also include a genuine large illustration.
if (img.closest(citationSelector) && !(rect.width > 48 && rect.height > 48 || !rect.width && img.naturalWidth > 64 && img.naturalHeight > 64)) return true;
return rect.width > 0 && rect.height > 0 && rect.width <= 32 && rect.height <= 32 &&
(img.getAttribute('role') === 'presentation' || img.alt === '' || img.naturalWidth <= 64 && img.naturalHeight <= 64);
}
function sourceBadges(node) {
const badges = new Set();
const hasBodyImage = el => [...el.querySelectorAll('img')].some(img => !decorativeImage(img));
node.querySelectorAll(citationSelector).forEach(el => {
if (el.tagName !== 'IMG' && !hasBodyImage(el)) badges.add(el);
});
// Some ChatGPT source pills have only a popover marker and a favicon,
// without any citation attributes. Identify the compact control as a whole.
node.querySelectorAll('img').forEach(img => {
if (!decorativeImage(img)) return;
for (let el = img.parentElement; el && el !== node; el = el.parentElement) {
if (el.matches('p,li,blockquote,pre,figure')) break;
const control = el.matches('a,button,[role="button"],span[data-state],span.inline-flex[class*="rounded"]');
if (control && el.textContent.trim().length <= 160 && !hasBodyImage(el)) {
badges.add(el); break;
}
}
});
return badges;
}
function rasterImage(loaded) {
const canvas = document.createElement('canvas');
const factor = Math.min(1, 1600 / loaded.naturalWidth, 2400 / loaded.naturalHeight);
canvas.width = Math.max(1, Math.round(loaded.naturalWidth * factor)); canvas.height = Math.max(1, Math.round(loaded.naturalHeight * factor));
canvas.getContext('2d').drawImage(loaded, 0, 0, canvas.width, canvas.height);
const data = canvas.toDataURL('image/png'); canvas.width = 1; canvas.height = 1; return data;
}
const appearanceProps = ['display','visibility','box-sizing','flex-direction','flex-wrap','flex-grow','flex-shrink','flex-basis','justify-content','justify-items','justify-self','align-items','align-self','align-content','order','grid-column','grid-row','grid-auto-flow','column-gap','row-gap','padding-top','padding-right','padding-bottom','padding-left','margin-top','margin-right','margin-bottom','margin-left','border-top','border-right','border-bottom','border-left','border-radius','border-collapse','border-spacing','background-color','background-image','background-size','background-position','background-repeat','box-shadow','opacity','color','font-family','font-size','font-weight','font-style','line-height','letter-spacing','text-align','text-decoration','text-transform','white-space','vertical-align','list-style-type','list-style-position','word-break','overflow-wrap','object-fit','object-position'];
function preserveAppearance(el, copy, root = false, css = getComputedStyle(el)) {
for (const p of appearanceProps) copy.style.setProperty(p, css.getPropertyValue(p));
for (const p of ['column-count','column-rule','grid-auto-rows','grid-auto-columns']) copy.style.setProperty(p, css.getPropertyValue(p));
// Resolve source grid tracks into proportions so wide cards fit the PNG width.
for (const p of ['grid-template-columns','grid-template-rows']) {
const tracks = css.getPropertyValue(p);
if (p.endsWith('columns') && /^(?:[\d.]+px\s*)+$/.test(tracks)) {
copy.style.setProperty(p, tracks.replace(/([\d.]+)px/g, 'minmax(0,$1fr)'));
} else if (!p.endsWith('rows') || tracks === 'none') copy.style.setProperty(p, tracks);
}
copy.style.maxWidth = '100%'; copy.style.minWidth = '0';
copy.style.boxSizing = 'border-box';
// Keep content responsive without copying the viewport's fixed pixel widths.
const rect = el.getBoundingClientRect(), parent = el.parentElement;
const parentCSS = parent && getComputedStyle(parent), parentRect = parent?.getBoundingClientRect();
const parentWidth = parentRect && parentRect.width - parseFloat(parentCSS.paddingLeft || 0) - parseFloat(parentCSS.paddingRight || 0);
if (root) { copy.style.width = '100%'; copy.style.margin = '0'; }
else if (!/^(inline|contents)/.test(css.display) && !/^(table-row|table-cell)/.test(css.display) &&
!/flex|grid/.test(parentCSS?.display || '') && rect.width > 0 && parentWidth > 0 && rect.width < parentWidth * .95) {
copy.style.width = `${Math.min(100, rect.width / parentWidth * 100)}%`;
}
if (el.tagName === 'TABLE') { copy.style.width = '100%'; copy.style.tableLayout = 'fixed'; }
if (/^(PRE|CODE)$/.test(el.tagName)) { copy.style.whiteSpace = 'pre-wrap'; copy.style.overflowWrap = 'anywhere'; }
if (css.position === 'absolute') {
copy.style.position = 'absolute';
for (const p of ['top','right','bottom','left','z-index']) copy.style.setProperty(p, css.getPropertyValue(p));
// Absolute offsets refer to the positioned ancestor, not necessarily the parent.
const containing = el.offsetParent;
if (containing?.getBoundingClientRect().width > 0) {
for (const p of ['left','right']) if (css.getPropertyValue(p).endsWith('px')) copy.style.setProperty(p, `${parseFloat(css.getPropertyValue(p)) / containing.clientWidth * 100}%`);
copy.style.width = `${Math.min(100, rect.width / containing.clientWidth * 100)}%`;
}
copy.style.minHeight = rect.height > 0 ? `${rect.height}px` : css.height;
} else if (css.position === 'relative') copy.style.position = 'relative';
// Only all-positioned diagrams lack intrinsic height. A floating helper in
// normal prose must never freeze that prose to its old viewport height.
const positions = [...el.children].map(child => getComputedStyle(child));
const hasText = [...el.childNodes].some(child => child.nodeType === Node.TEXT_NODE && child.textContent.trim());
const hasFlow = hasText || positions.some(style => style.display !== 'none' && !['absolute','fixed'].includes(style.position));
if (!root && !hasFlow && (positions.some(style => style.position === 'absolute') || !el.children.length)) {
copy.style.height = rect.height > 0 ? `${rect.height}px` : css.height;
}
}
function preservePseudo(el, copy) {
if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') return;
for (const which of ['::before','::after']) {
const css = getComputedStyle(el, which), content = css.content;
if (css.display === 'none' || !content || /^(none|normal)$/.test(content)) continue;
// Materialize generated text/dots/lines; skip external generated images.
if (!/^"(?:[^"\\]|\\.)*"$/.test(content)) continue;
const pseudo = document.createElement('span');
pseudo.dataset.gptSharePseudo = which.slice(2);
try { pseudo.textContent = JSON.parse(content); } catch { pseudo.textContent = content.slice(1,-1); }
preserveAppearance(el, pseudo, false, css);
pseudo.style.width = css.width; pseudo.style.height = css.height;
pseudo.style.minHeight = css.minHeight;
for (const p of ['position','top','right','bottom','left','transform','transform-origin','z-index']) pseudo.style.setProperty(p, css.getPropertyValue(p));
if (which === '::before') copy.prepend(pseudo); else copy.append(pseudo);
}
}
function sourceLabel(el) {
const text = el.textContent.replace(/\s+/g,' ').trim();
if (!text) return document.createTextNode('');
const badge = document.createElement('span'); badge.dataset.gptShareSource = '';
badge.textContent = text;
badge.style.cssText = 'display:inline-block;vertical-align:middle;box-sizing:border-box;max-width:180px;padding:1px 7px;margin:0 3px;border-radius:10px;background:#f0f2f4;color:#5d6771;font:11px/18px Arial,"PingFang SC",sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left';
return badge;
}
function cardBackground(el) {
for (let current = el; current; current = current.parentElement) {
const bg = getComputedStyle(current).backgroundColor;
if (bg !== 'transparent' && !/rgba\([^)]*,\s*0\)$/.test(bg)) return bg;
}
return '#fff';
}
function exportSVG(svg, presentation) {
const copy = svg.cloneNode(true);
const originals = [svg, ...svg.querySelectorAll('*')];
const clones = [copy, ...copy.querySelectorAll('*')];
originals.forEach((el,i) => {
const c = clones[i], css = getComputedStyle(el);
for (const attr of [...c.attributes]) {
if (/^on/i.test(attr.name) || /(?:href)$/i.test(attr.name) && !attr.value.startsWith('#')) c.removeAttribute(attr.name);
}
for (const p of ['color','fill','fill-opacity','fill-rule','stroke','stroke-width','stroke-linecap','stroke-linejoin','stroke-dasharray','stroke-opacity','opacity','font-family','font-size','font-weight']) c.style.setProperty(p, css.getPropertyValue(p));
});
copy.querySelectorAll('script,style,foreignObject,iframe,image').forEach(el => el.remove());
copy.setAttribute('xmlns','http://www.w3.org/2000/svg');
const rect = svg.getBoundingClientRect();
const width = rect.width || Number(svg.getAttribute('width')) || 24;
const height = rect.height || Number(svg.getAttribute('height')) || 24;
if (!copy.hasAttribute('viewBox')) copy.setAttribute('viewBox',`0 0 ${width} ${height}`);
// Rasterize SVG at export resolution while retaining its CSS display size.
copy.setAttribute('width',width * 2); copy.setAttribute('height',height * 2);
copy.style.width = `${width * 2}px`; copy.style.height = `${height * 2}px`;
const img = document.createElement('img');
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(copy))}`;
img.alt = svg.getAttribute('aria-label') || '';
img.style.cssText = presentation;
img.style.width = `${width}px`; img.style.height = 'auto'; img.style.maxWidth = '100%';
img.style.verticalAlign = 'middle'; img.style.flexShrink = '0';
return img;
}
function cleanMessage(node) {
const copy = node.cloneNode(true);
const original = [node, ...node.querySelectorAll('*')];
const cloned = [copy, ...copy.querySelectorAll('*')];
const badges = sourceBadges(node);
original.forEach((el, i) => {
const c = cloned[i];
if (c !== copy && !copy.contains(c)) return;
if (badges.has(el)) { c.replaceWith(sourceLabel(el)); return; }
c.removeAttribute('id');
for (const attr of [...c.attributes]) if (/^on/i.test(attr.name)) c.removeAttribute(attr.name);
c.removeAttribute('style');
preserveAppearance(el, c, el === node);
if (el.tagName.toLowerCase() === 'svg' && !el.closest('.katex')) {
c.replaceWith(exportSVG(el, c.style.cssText)); return;
}
if (el.closest('.katex')) {
const css = getComputedStyle(el);
for (const p of mathProps) c.style.setProperty(p, css.getPropertyValue(p));
}
if (el.tagName === 'CANVAS') {
try { const img = document.createElement('img'); img.src = el.toDataURL(); img.style.cssText = c.style.cssText; img.style.width = `${el.getBoundingClientRect().width}px`; img.style.height = 'auto'; c.replaceWith(img); }
catch { c.replaceWith(document.createTextNode('[画布无法读取]')); }
}
if (el.tagName === 'IMG') {
if (decorativeImage(el)) { c.remove(); return; }
c.src = el.currentSrc || el.src || el.getAttribute('data-src') || '';
// Capture an already loaded, origin-clean image without requesting it again.
if (el.complete && el.naturalWidth) { try { c.src = rasterImage(el); } catch { /* use the image loader below */ } }
for (const attr of ['srcset','loading','width','height','crossorigin','referrerpolicy']) c.removeAttribute(attr);
const rect = el.getBoundingClientRect();
c.style.width = rect.width > 0 ? `${rect.width}px` : 'auto'; c.style.height = 'auto';
}
// Image cards may mark the visible image aria-hidden because the button labels it.
const hasImage = el.tagName === 'IMG' || el.querySelector('img');
if (el.hidden || el.getAttribute('aria-hidden') === 'true' && !hasImage && !el.closest('.katex')) c.remove();
if (copy.contains(c)) preservePseudo(el, c);
});
// Retain the source palette together, even when the outer card uses a different theme.
copy.style.backgroundColor = cardBackground(node);
copy.dataset.gptShareAppearance = '';
copy.querySelectorAll('script,style,iframe,video,audio,form,input,textarea,select,.turn-action-controls,.sr-only,.katex-mathml').forEach(el => el.remove());
//