您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在腾讯元宝对话中添加一键复制Markdown按钮(含思考过程),可通过油猴菜单配置导出选项。Refactored for modularity.
- // ==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();
- }
- })();