// ==UserScript==
// @name Yuanbao Markdown Copy
// @namespace http://tampermonkey.net/
// @version 0.9
// @description 在腾讯元宝对话中添加一键复制Markdown按钮(含思考过程),可通过油猴菜单配置导出选项。Refactored for modularity.
// @author LouisLUO
// @match https://yuanbao.tencent.com/*
// @icon https://cdn-bot.hunyuan.tencent.com/logo-v2.png
// @grant GM_registerMenuCommand
// @license MIT
// ==/UserScript==
// 鸣谢:本脚本部分思路和实现参考了 [腾讯元宝对话导出器 | Tencent Yuanbao Exporter](https://greasyfork.org/zh-CN/scripts/532431-%E8%85%BE%E8%AE%AF%E5%85%83%E5%AE%9D%E5%AF%B9%E8%AF%9D%E5%AF%BC%E5%87%BA%E5%99%A8-tencent-yuanbao-exporter)(by Gao + Gemini 2.5 Pro),并受益于 GitHub Copilot 及 GPT-4.1 的辅助。
// Thanks: Some logic and implementation are inspired by [腾讯元宝对话导出器 | Tencent Yuanbao Exporter](https://greasyfork.org/zh-CN/scripts/532431-%E8%85%BE%E8%AE%AF%E5%85%83%E5%AE%9D%E5%AF%B9%E8%AF%9D%E5%AF%BC%E5%87%BA%E5%99%A8-tencent-yuanbao-exporter) (by Gao + Gemini 2.5 Pro), with help from GitHub Copilot and GPT-4.1.
(function () {
'use strict';
// --- Configuration & Constants ---
const SCRIPT_NAME = 'Yuanbao Markdown Copy';
const BTN_STYLE = {
background: '#13172c', // 修正: 使用 background 而不是 bg
backgroundHover: '#24293c', // 新增: hover 时的背景色
color: '#efefef',
marginLeft: '8px',
padding: '2px 8px',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'background 0.2s',
};
const EXPORT_BTN_STYLE = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: '1 1 0',
padding: '4px 0',
margin: '4px',
gap: '4px',
};
// SVG Icons
const ICON_EXPORT_ALL = `
<svg fill="${BTN_STYLE.color}" width="1em" height="1em" viewBox="0 0 20 20" style="margin-right:4px;vertical-align:middle;" xmlns="http://www.w3.org/2000/svg"><path d="M15 15H2V6h2.595s.689-.896 2.17-2H1a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h15a1 1 0 0 0 1-1v-3.746l-2 1.645V15zm-1.639-6.95v3.551L20 6.4l-6.639-4.999v3.131C5.3 4.532 5.3 12.5 5.3 12.5c2.282-3.748 3.686-4.45 8.061-4.45z"/></svg>
`;
const ICON_DIALOGUE = `
<svg fill="${BTN_STYLE.color}" width="1em" height="1em" viewBox="0 0 24 24" style="margin-right:4px;vertical-align:middle;" xmlns="http://www.w3.org/2000/svg"><path d="M21 2H3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5v3.382a1 1 0 0 0 1.447.894L15.764 18H21a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Zm-1 14h-5.382a1 1 0 0 0-.447.105L10 17.618V16a1 1 0 0 0-1-1H4V4h16ZM7 7h10v2H7Zm0 4h7v2H7Z"/></svg>
`;
// --- State Management ---
let state = {
latestDetailResponse: null,
latestResponseSize: 0,
latestResponseUrl: null,
lastUpdateTime: null
};
// --- Settings Management ---
const DEFAULT_SETTINGS = {
autoInjectCopyBtn: true,
exportFormat: 'markdown', // Reserved for future extensions
replaceFormulas: true,
exportThinkProcess: true,
thinkProcessFormat: 'tag', // 'tag' or 'markdown'
keepSearchResults: true,
headerDowngrade: false
};
function getSettings() {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem('yuanbao_md_settings') || '{}') };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(settings) {
localStorage.setItem('yuanbao_md_settings', JSON.stringify(settings));
}
function showSettingsDialog() {
const settings = getSettings();
const html = `
<div style="font-size:14px; line-height: 1.8;">
<label><input type="checkbox" id="autoInjectCopyBtn" ${settings.autoInjectCopyBtn ? 'checked' : ''}> 自动注入“复制MD”按钮 (刷新生效)</label><br>
<label><input type="checkbox" id="replaceFormulas" ${settings.replaceFormulas ? 'checked' : ''}> 替换行内/块公式语法 (<code>\\(..\\)</code> -> <code>$...$</code>, <code>\\[..\\]</code> -> <code>$$...$$</code>)</label><br>
<label><input type="checkbox" id="exportThinkProcess" ${settings.exportThinkProcess ? 'checked' : ''}> 导出思考过程</label><br>
<label style="padding-left: 20px;">
思考过程格式:
<select id="thinkProcessFormat" ${!settings.exportThinkProcess ? 'disabled' : ''}>
<option value="tag" ${settings.thinkProcessFormat === 'tag' ? 'selected' : ''}><think>标签</option>
<option value="markdown" ${settings.thinkProcessFormat === 'markdown' ? 'selected' : ''}>Markdown引用</option>
</select>
</label><br>
<label><input type="checkbox" id="keepSearchResults" ${settings.keepSearchResults ? 'checked' : ''}> 保留网页搜索内容和脚标</label><br>
<label><input type="checkbox" id="headerDowngrade" ${settings.headerDowngrade ? 'checked' : ''}> 标题降级 (例: # -> ##)</label><br>
<label style="display:none;">导出格式:<select id="exportFormat"><option value="markdown" ${settings.exportFormat === 'markdown' ? 'selected' : ''}>Markdown</option></select></label>
</div>
`;
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const modal = document.createElement('div');
Object.assign(modal.style, {
position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%,-50%)',
background: '#222', color: '#fff', padding: '24px', borderRadius: '12px',
zIndex: 99999, boxShadow: '0 2px 16px #0008'
});
modal.appendChild(wrapper);
const btnSave = document.createElement('button');
btnSave.textContent = '保存';
btnSave.style.margin = '16px 8px 0 0';
btnSave.onclick = () => {
const newSettings = {
autoInjectCopyBtn: wrapper.querySelector('#autoInjectCopyBtn').checked,
exportFormat: wrapper.querySelector('#exportFormat').value,
replaceFormulas: wrapper.querySelector('#replaceFormulas').checked,
exportThinkProcess: wrapper.querySelector('#exportThinkProcess').checked,
thinkProcessFormat: wrapper.querySelector('#thinkProcessFormat').value,
keepSearchResults: wrapper.querySelector('#keepSearchResults').checked,
headerDowngrade: wrapper.querySelector('#headerDowngrade').checked
};
saveSettings(newSettings);
document.body.removeChild(modal);
alert('设置已保存,部分设置需刷新页面生效');
};
const btnCancel = document.createElement('button');
btnCancel.textContent = '取消';
btnCancel.onclick = () => document.body.removeChild(modal);
modal.appendChild(btnSave);
modal.appendChild(btnCancel);
document.body.appendChild(modal);
const exportThinkProcessCheckbox = wrapper.querySelector('#exportThinkProcess');
const thinkProcessFormatSelect = wrapper.querySelector('#thinkProcessFormat');
exportThinkProcessCheckbox.addEventListener('change', function () {
thinkProcessFormatSelect.disabled = !this.checked;
});
}
// --- Network Interception ---
function processYuanbaoResponse(text, url) {
if (!url || !url.includes('/api/user/agent/conversation/v1/detail')) return;
try {
if (text && text.includes('"convs":') && text.includes('"createTime":')) {
state.latestDetailResponse = text;
state.latestResponseSize = text.length;
state.latestResponseUrl = url;
state.lastUpdateTime = new Date().toLocaleTimeString();
}
} catch (e) { console.error(`${SCRIPT_NAME}: Error processing response`, e); }
}
function setupNetworkInterceptors() {
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const url = args[0] instanceof Request ? args[0].url : args[0];
const response = await originalFetch.apply(this, args);
if (typeof url === 'string' && url.includes('/api/user/agent/conversation/v1/detail')) {
response.clone().text().then(text => processYuanbaoResponse(text, url));
}
return response;
};
const originalXhrOpen = XMLHttpRequest.prototype.open;
const originalXhrSend = XMLHttpRequest.prototype.send;
const xhrUrlMap = new WeakMap();
XMLHttpRequest.prototype.open = function (method, url) {
xhrUrlMap.set(this, url);
if (typeof url === 'string' && url.includes('/api/user/agent/conversation/v1/detail')) {
this.addEventListener('load', function () {
if (this.readyState === 4 && this.status === 200) {
processYuanbaoResponse(this.responseText, xhrUrlMap.get(this));
}
});
}
return originalXhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () { return originalXhrSend.apply(this, arguments); };
}
// --- Markdown Conversion Utilities ---
function adjustHeaderLevels(text, increaseBy = 1) {
if (!text) return '';
let adjustedText = text.replace(/^(#+)(\s*)(.*?)\s*$/gm, (match, hashes, space, content) =>
'#'.repeat(hashes.length + increaseBy) + ' ' + content.trim()
);
adjustedText = adjustedText.replace(/^>\s*(#+)(\s*)(.*?)\s*$/gm, (match, blockquotePrefix, hashes, space, content) =>
blockquotePrefix + '#'.repeat(hashes.length + increaseBy) + ' ' + content.trim()
);
return adjustedText;
}
function applyFormulaReplacements(text, shouldReplace) {
if (!shouldReplace || !text) return text || '';
return text
.replace(/\\\((.+?)\\\)/g, (m, p1) => `$${p1}$`)
.replace(/\\\[(.+?)\\\]/gs, (m, p1) => `$$${p1}$$`);
}
function formatThinkContent(thinkContent, settings) {
let content = applyFormulaReplacements(thinkContent, settings.replaceFormulas);
if (settings.thinkProcessFormat === 'markdown') {
return `> ${content.replace(/\n/g, '\n> ')}\n\n`;
}
return `<think>\n${content}\n</think>\n\n`;
}
function processContentBlock(block, settings, refsContext) {
let markdown = '';
switch (block.type) {
case 'text': {
let msg = applyFormulaReplacements(block.msg || '', settings.replaceFormulas);
if (settings.keepSearchResults && msg.includes('(@ref)')) {
msg = msg.replace(/\[(\d+)\]\(@ref\)/g, (_, n) => `[^${n}]`); // Placeholder, will be adjusted by searchGuid
} else if (!settings.keepSearchResults && msg.includes('(@ref)')) {
msg = msg.replace(/\[\d+\]\(@ref\)/g, '').trim();
}
markdown += `${msg}\n\n`;
break;
}
case 'think':
if (settings.exportThinkProcess && block.content) {
markdown += formatThinkContent(block.content, settings);
}
break;
case 'searchGuid': {
let text = applyFormulaReplacements(block.msg || block.content || '', settings.replaceFormulas);
if (settings.keepSearchResults) {
let blockScopedRefStartIndex = refsContext.nextRefIdx;
if (block.docs && block.docs.length > 0) {
block.docs.forEach(doc => {
refsContext.refsArray.push({
idx: refsContext.nextRefIdx++,
title: doc.title || '无标题',
url: doc.url || '#'
});
});
}
let currentRefPlaceholderIdx = blockScopedRefStartIndex;
text = text.replace(/\[(\d+)\]\(@ref\)/g, () => `[^${currentRefPlaceholderIdx++}]`);
markdown += text + '\n\n';
} else if (text) {
text = text.replace(/\[\d+\]\(@ref\)/g, '').trim();
markdown += text + '\n\n';
}
break;
}
case 'image':
case 'code':
case 'pdf':
markdown += `[${block.fileName || '未知文件'}](${block.url || '#'})\n\n`;
break;
}
return markdown;
}
function processSpeech(speech, settings, refsContext) {
let markdown = '';
if (speech.content && speech.content.length > 0) {
speech.content.forEach(block => {
markdown += processContentBlock(block, settings, refsContext);
});
}
return markdown;
}
function extractUserMessageAndMedia(turn, settings) {
let userTextMsg = '';
let mediaMarkdown = '';
if (turn.speechesV2 && turn.speechesV2.length > 0 && turn.speechesV2[0].content) {
const textBlock = turn.speechesV2[0].content.find(block => block.type === 'text');
if (textBlock && typeof textBlock.msg === 'string') {
userTextMsg = textBlock.msg;
}
let uploadedMedia = [];
turn.speechesV2[0].content.forEach(block => {
if (block.type !== 'text' && block.fileName && block.url) {
uploadedMedia.push(`[${block.fileName || '未知文件'}](${block.url || '#'})`);
}
});
if (uploadedMedia.length > 0) {
mediaMarkdown = `\n${uploadedMedia.join('\n')}\n`;
}
}
// Fallback to displayPrompt if text message is still empty
if (!userTextMsg && typeof turn.displayPrompt === 'string') {
userTextMsg = turn.displayPrompt;
}
userTextMsg = applyFormulaReplacements(userTextMsg, settings.replaceFormulas);
return (userTextMsg + '\n' + mediaMarkdown).trim() + '\n';
}
function processTurnToMarkdown(turn, settings, refsContext, isFullExportContext = false) {
let markdown = '';
if (turn.speaker === 'human') {
if (isFullExportContext) {
markdown += (settings.headerDowngrade ? '> ## user\n' : '> # user\n');
}
markdown += extractUserMessageAndMedia(turn, settings);
} else if (turn.speaker === 'ai') {
if (isFullExportContext) {
markdown += (settings.headerDowngrade ? '> ## agent\n' : '> # agent\n');
}
if (turn.speechesV2 && turn.speechesV2.length > 0) {
turn.speechesV2.forEach(speech => {
markdown += processSpeech(speech, settings, refsContext);
});
}
}
return markdown;
}
function convertSingleTurnJsonToMarkdown(jsonData, targetTurnIndex, settings) {
if (!jsonData || !jsonData.convs || !Array.isArray(jsonData.convs)) {
return '# 错误:无效的JSON数据\n\n无法解析对话内容。';
}
const turn = jsonData.convs.find(t => t.index === targetTurnIndex);
if (!turn) return '';
let refsContext = { refsArray: [], nextRefIdx: 1 };
let markdownContent = processTurnToMarkdown(turn, settings, refsContext, false);
if (settings.keepSearchResults && refsContext.refsArray.length > 0) {
markdownContent += '\n';
refsContext.refsArray.forEach(ref => {
markdownContent += `[^${ref.idx}]: [${ref.title}](${ref.url})\n`;
});
}
if (settings.headerDowngrade) {
markdownContent = adjustHeaderLevels(markdownContent);
}
return markdownContent.trim();
}
function convertAllTurnsJsonToMarkdown(jsonData, settings) {
if (!jsonData || !Array.isArray(jsonData.convs)) {
return '# 错误:无效的JSON数据\n\n无法解析对话内容。';
}
let markdownContent = '';
let refsContext = { refsArray: [], nextRefIdx: 1 };
// jsonData.convs is newest first. Reverse to process oldest first for chronological output.
jsonData.convs.slice().reverse().forEach(turn => {
markdownContent += processTurnToMarkdown(turn, settings, refsContext, true);
});
if (settings.keepSearchResults && refsContext.refsArray.length > 0) {
markdownContent += '\n';
refsContext.refsArray.forEach(ref => {
markdownContent += `[^${ref.idx}]: [${ref.title}](${ref.url})\n`;
});
}
if (settings.headerDowngrade) {
// Note: Speaker headers are already downgraded by processTurnToMarkdown if isFullExportContext.
// This call will downgrade any other headers within the content.
markdownContent = adjustHeaderLevels(markdownContent);
}
return markdownContent.trim();
}
// --- UI Injection & Event Handlers ---
function createStyledButton(text, onclick, iconSvg = '', customStyles = {}) {
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = iconSvg + text;
Object.assign(btn.style, BTN_STYLE, customStyles); // Base style, then specific overrides
btn.onmouseover = () => { btn.style.background = BTN_STYLE.backgroundHover; };
btn.onmouseout = () => { btn.style.background = BTN_STYLE.background; };
btn.onclick = onclick;
// 确保初始状态下背景色正确
btn.style.background = BTN_STYLE.background;
return btn;
}
function injectCopyButtonToBubble(copyBtnElement, allBubbles, jsonData) {
if (copyBtnElement.parentElement.querySelector('.agent-chat__toolbar__copy-md')) return;
const mdBtn = createStyledButton('复制MD', null, '', { fontSize: '14px' }); // Use shared styling
mdBtn.title = '复制Markdown(接口数据)';
mdBtn.className = 'agent-chat__toolbar__copy-md';
mdBtn.onclick = function (e) {
e.stopPropagation();
const bubble = copyBtnElement.closest('.agent-chat__bubble');
if (!bubble) { alert('未找到对话泡'); return; }
const domIdx = allBubbles.indexOf(bubble);
const jsonConvs = jsonData && Array.isArray(jsonData.convs) ? jsonData.convs : [];
// Assuming jsonData.convs is newest first, and allBubbles is oldest first.
const jsonTargetIdx = jsonConvs.length - 1 - domIdx;
let targetTurnUniqueIndex = null;
if (jsonConvs[jsonTargetIdx]) {
targetTurnUniqueIndex = jsonConvs[jsonTargetIdx].index;
}
if (targetTurnUniqueIndex === null || targetTurnUniqueIndex === undefined) {
alert('无法匹配到正确的对话轮次');
return;
}
const settings = getSettings();
const md = convertSingleTurnJsonToMarkdown(jsonData, targetTurnUniqueIndex, settings);
if (!md) { alert('未提取到Markdown内容'); return; }
navigator.clipboard.writeText(md).then(() => {
mdBtn.title = '已复制!';
mdBtn.style.opacity = 0.5;
setTimeout(() => {
mdBtn.title = '复制Markdown(接口数据)';
mdBtn.style.opacity = 1;
}, 1000);
}).catch(err => {
console.error(`${SCRIPT_NAME}: Failed to copy: `, err);
alert('复制失败,详情请查看控制台。');
});
};
copyBtnElement.parentElement.insertBefore(mdBtn, copyBtnElement.nextSibling);
}
function injectCopyButtonsToAllBubbles() {
const settings = getSettings();
if (!settings.autoInjectCopyBtn || !state.latestDetailResponse) return;
let jsonData;
try {
jsonData = JSON.parse(state.latestDetailResponse);
} catch (e) {
console.error(`${SCRIPT_NAME}: JSON parsing failed for injecting copy buttons.`, e);
// Do not alert here as this runs frequently.
return;
}
if (!jsonData || !jsonData.convs) return;
const allBubbles = Array.from(document.querySelectorAll('.agent-chat__bubble'));
document.querySelectorAll('.agent-chat__toolbar__copy').forEach(copyBtn => {
injectCopyButtonToBubble(copyBtn, allBubbles, jsonData);
});
}
function injectExportButtonsToToolbar(toolbarElement) {
if (!toolbarElement || toolbarElement.dataset.mdInjected) return;
toolbarElement.innerHTML = ''; // Clear existing content
Object.assign(toolbarElement.style, {
display: 'flex', gap: '4px', width: '150px', alignItems: 'center', height: '34px'
});
const settings = getSettings();
const btnAllOnClick = () => {
if (!state.latestDetailResponse) {
alert('未捕获到对话数据,请刷新页面或重新进入对话。');
return;
}
let jsonData;
try {
jsonData = JSON.parse(state.latestDetailResponse);
} catch (e) { alert('JSON 解析失败'); return; }
const md = convertAllTurnsJsonToMarkdown(jsonData, settings);
if (!md) { alert('未提取到Markdown内容'); return; }
navigator.clipboard.writeText(md).then(() => {
alert('全部对话已复制到剪贴板!');
}).catch(err => {
console.error(`${SCRIPT_NAME}: Failed to copy all: `, err);
alert('复制失败,详情请查看控制台。');
});
};
const btnAll = createStyledButton('全部', btnAllOnClick, ICON_EXPORT_ALL, EXPORT_BTN_STYLE);
const btnDialogue = createStyledButton('对话', injectCopyButtonsToAllBubbles, ICON_DIALOGUE, EXPORT_BTN_STYLE);
toolbarElement.appendChild(btnAll);
toolbarElement.appendChild(btnDialogue);
toolbarElement.dataset.mdInjected = '1';
}
// --- DOM Observation ---
function observeDOMChanges() {
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('agent-dialogue__tool')) {
injectExportButtonsToToolbar(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('.agent-dialogue__tool').forEach(el => injectExportButtonsToToolbar(el));
}
}
});
}
}
// Always try to inject copy buttons if new nodes are added and auto-inject is on
if (getSettings().autoInjectCopyBtn) {
injectCopyButtonsToAllBubbles();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// --- Main Initialization ---
function init() {
setupNetworkInterceptors();
observeDOMChanges();
// Initial call to inject buttons if content is already present
if (getSettings().autoInjectCopyBtn) {
injectCopyButtonsToAllBubbles();
}
// Attempt to inject toolbar buttons if already present
const existingToolbar = document.querySelector('.agent-dialogue__tool');
if (existingToolbar) {
injectExportButtonsToToolbar(existingToolbar);
}
}
// --- Script Execution ---
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('脚本设置', showSettingsDialog);
}
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();