Yuanbao Markdown Copy

在腾讯元宝对话中添加一键复制Markdown按钮(含思考过程),可通过油猴菜单配置导出选项。Refactored for modularity.

  1. // ==UserScript==
  2. // @name Yuanbao Markdown Copy
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.9
  5. // @description 在腾讯元宝对话中添加一键复制Markdown按钮(含思考过程),可通过油猴菜单配置导出选项。Refactored for modularity.
  6. // @author LouisLUO
  7. // @match https://yuanbao.tencent.com/*
  8. // @icon https://cdn-bot.hunyuan.tencent.com/logo-v2.png
  9. // @grant GM_registerMenuCommand
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. // 鸣谢:本脚本部分思路和实现参考了 [腾讯元宝对话导出器 | 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 的辅助。
  14. // 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.
  15.  
  16. (function () {
  17. 'use strict';
  18.  
  19. // --- Configuration & Constants ---
  20. const SCRIPT_NAME = 'Yuanbao Markdown Copy';
  21. const BTN_STYLE = {
  22. background: '#13172c', // 修正: 使用 background 而不是 bg
  23. backgroundHover: '#24293c', // 新增: hover 时的背景色
  24. color: '#efefef',
  25. marginLeft: '8px',
  26. padding: '2px 8px',
  27. border: 'none',
  28. borderRadius: '8px',
  29. cursor: 'pointer',
  30. fontSize: '14px',
  31. transition: 'background 0.2s',
  32. };
  33.  
  34. const EXPORT_BTN_STYLE = {
  35. display: 'flex',
  36. alignItems: 'center',
  37. justifyContent: 'center',
  38. flex: '1 1 0',
  39. padding: '4px 0',
  40. margin: '4px',
  41. gap: '4px',
  42. };
  43.  
  44. // SVG Icons
  45. const ICON_EXPORT_ALL = `
  46. <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>
  47. `;
  48. const ICON_DIALOGUE = `
  49. <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>
  50. `;
  51.  
  52. // --- State Management ---
  53. let state = {
  54. latestDetailResponse: null,
  55. latestResponseSize: 0,
  56. latestResponseUrl: null,
  57. lastUpdateTime: null
  58. };
  59.  
  60. // --- Settings Management ---
  61. const DEFAULT_SETTINGS = {
  62. autoInjectCopyBtn: true,
  63. exportFormat: 'markdown', // Reserved for future extensions
  64. replaceFormulas: true,
  65. exportThinkProcess: true,
  66. thinkProcessFormat: 'tag', // 'tag' or 'markdown'
  67. keepSearchResults: true,
  68. headerDowngrade: false
  69. };
  70.  
  71. function getSettings() {
  72. try {
  73. return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem('yuanbao_md_settings') || '{}') };
  74. } catch {
  75. return { ...DEFAULT_SETTINGS };
  76. }
  77. }
  78.  
  79. function saveSettings(settings) {
  80. localStorage.setItem('yuanbao_md_settings', JSON.stringify(settings));
  81. }
  82.  
  83. function showSettingsDialog() {
  84. const settings = getSettings();
  85. const html = `
  86. <div style="font-size:14px; line-height: 1.8;">
  87. <label><input type="checkbox" id="autoInjectCopyBtn" ${settings.autoInjectCopyBtn ? 'checked' : ''}> 自动注入“复制MD”按钮 (刷新生效)</label><br>
  88. <label><input type="checkbox" id="replaceFormulas" ${settings.replaceFormulas ? 'checked' : ''}> 替换行内/块公式语法 (<code>\\(..\\)</code> -> <code>$...$</code>, <code>\\[..\\]</code> -> <code>$$...$$</code>)</label><br>
  89. <label><input type="checkbox" id="exportThinkProcess" ${settings.exportThinkProcess ? 'checked' : ''}> 导出思考过程</label><br>
  90. <label style="padding-left: 20px;">
  91. 思考过程格式:
  92. <select id="thinkProcessFormat" ${!settings.exportThinkProcess ? 'disabled' : ''}>
  93. <option value="tag" ${settings.thinkProcessFormat === 'tag' ? 'selected' : ''}>&lt;think&gt;标签</option>
  94. <option value="markdown" ${settings.thinkProcessFormat === 'markdown' ? 'selected' : ''}>Markdown引用</option>
  95. </select>
  96. </label><br>
  97. <label><input type="checkbox" id="keepSearchResults" ${settings.keepSearchResults ? 'checked' : ''}> 保留网页搜索内容和脚标</label><br>
  98. <label><input type="checkbox" id="headerDowngrade" ${settings.headerDowngrade ? 'checked' : ''}> 标题降级 (例: # -> ##)</label><br>
  99. <label style="display:none;">导出格式:<select id="exportFormat"><option value="markdown" ${settings.exportFormat === 'markdown' ? 'selected' : ''}>Markdown</option></select></label>
  100. </div>
  101. `;
  102. const wrapper = document.createElement('div');
  103. wrapper.innerHTML = html;
  104.  
  105. const modal = document.createElement('div');
  106. Object.assign(modal.style, {
  107. position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%,-50%)',
  108. background: '#222', color: '#fff', padding: '24px', borderRadius: '12px',
  109. zIndex: 99999, boxShadow: '0 2px 16px #0008'
  110. });
  111. modal.appendChild(wrapper);
  112.  
  113. const btnSave = document.createElement('button');
  114. btnSave.textContent = '保存';
  115. btnSave.style.margin = '16px 8px 0 0';
  116. btnSave.onclick = () => {
  117. const newSettings = {
  118. autoInjectCopyBtn: wrapper.querySelector('#autoInjectCopyBtn').checked,
  119. exportFormat: wrapper.querySelector('#exportFormat').value,
  120. replaceFormulas: wrapper.querySelector('#replaceFormulas').checked,
  121. exportThinkProcess: wrapper.querySelector('#exportThinkProcess').checked,
  122. thinkProcessFormat: wrapper.querySelector('#thinkProcessFormat').value,
  123. keepSearchResults: wrapper.querySelector('#keepSearchResults').checked,
  124. headerDowngrade: wrapper.querySelector('#headerDowngrade').checked
  125. };
  126. saveSettings(newSettings);
  127. document.body.removeChild(modal);
  128. alert('设置已保存,部分设置需刷新页面生效');
  129. };
  130.  
  131. const btnCancel = document.createElement('button');
  132. btnCancel.textContent = '取消';
  133. btnCancel.onclick = () => document.body.removeChild(modal);
  134.  
  135. modal.appendChild(btnSave);
  136. modal.appendChild(btnCancel);
  137. document.body.appendChild(modal);
  138.  
  139. const exportThinkProcessCheckbox = wrapper.querySelector('#exportThinkProcess');
  140. const thinkProcessFormatSelect = wrapper.querySelector('#thinkProcessFormat');
  141. exportThinkProcessCheckbox.addEventListener('change', function () {
  142. thinkProcessFormatSelect.disabled = !this.checked;
  143. });
  144. }
  145.  
  146. // --- Network Interception ---
  147. function processYuanbaoResponse(text, url) {
  148. if (!url || !url.includes('/api/user/agent/conversation/v1/detail')) return;
  149. try {
  150. if (text && text.includes('"convs":') && text.includes('"createTime":')) {
  151. state.latestDetailResponse = text;
  152. state.latestResponseSize = text.length;
  153. state.latestResponseUrl = url;
  154. state.lastUpdateTime = new Date().toLocaleTimeString();
  155. }
  156. } catch (e) { console.error(`${SCRIPT_NAME}: Error processing response`, e); }
  157. }
  158.  
  159. function setupNetworkInterceptors() {
  160. const originalFetch = window.fetch;
  161. window.fetch = async function (...args) {
  162. const url = args[0] instanceof Request ? args[0].url : args[0];
  163. const response = await originalFetch.apply(this, args);
  164. if (typeof url === 'string' && url.includes('/api/user/agent/conversation/v1/detail')) {
  165. response.clone().text().then(text => processYuanbaoResponse(text, url));
  166. }
  167. return response;
  168. };
  169.  
  170. const originalXhrOpen = XMLHttpRequest.prototype.open;
  171. const originalXhrSend = XMLHttpRequest.prototype.send;
  172. const xhrUrlMap = new WeakMap();
  173. XMLHttpRequest.prototype.open = function (method, url) {
  174. xhrUrlMap.set(this, url);
  175. if (typeof url === 'string' && url.includes('/api/user/agent/conversation/v1/detail')) {
  176. this.addEventListener('load', function () {
  177. if (this.readyState === 4 && this.status === 200) {
  178. processYuanbaoResponse(this.responseText, xhrUrlMap.get(this));
  179. }
  180. });
  181. }
  182. return originalXhrOpen.apply(this, arguments);
  183. };
  184. XMLHttpRequest.prototype.send = function () { return originalXhrSend.apply(this, arguments); };
  185. }
  186.  
  187. // --- Markdown Conversion Utilities ---
  188. function adjustHeaderLevels(text, increaseBy = 1) {
  189. if (!text) return '';
  190. let adjustedText = text.replace(/^(#+)(\s*)(.*?)\s*$/gm, (match, hashes, space, content) =>
  191. '#'.repeat(hashes.length + increaseBy) + ' ' + content.trim()
  192. );
  193. adjustedText = adjustedText.replace(/^>\s*(#+)(\s*)(.*?)\s*$/gm, (match, blockquotePrefix, hashes, space, content) =>
  194. blockquotePrefix + '#'.repeat(hashes.length + increaseBy) + ' ' + content.trim()
  195. );
  196. return adjustedText;
  197. }
  198.  
  199. function applyFormulaReplacements(text, shouldReplace) {
  200. if (!shouldReplace || !text) return text || '';
  201. return text
  202. .replace(/\\\((.+?)\\\)/g, (m, p1) => `$${p1}$`)
  203. .replace(/\\\[(.+?)\\\]/gs, (m, p1) => `$$${p1}$$`);
  204. }
  205.  
  206. function formatThinkContent(thinkContent, settings) {
  207. let content = applyFormulaReplacements(thinkContent, settings.replaceFormulas);
  208. if (settings.thinkProcessFormat === 'markdown') {
  209. return `> ${content.replace(/\n/g, '\n> ')}\n\n`;
  210. }
  211. return `<think>\n${content}\n</think>\n\n`;
  212. }
  213.  
  214. function processContentBlock(block, settings, refsContext) {
  215. let markdown = '';
  216. switch (block.type) {
  217. case 'text': {
  218. let msg = applyFormulaReplacements(block.msg || '', settings.replaceFormulas);
  219. if (settings.keepSearchResults && msg.includes('(@ref)')) {
  220. msg = msg.replace(/\[(\d+)\]\(@ref\)/g, (_, n) => `[^${n}]`); // Placeholder, will be adjusted by searchGuid
  221. } else if (!settings.keepSearchResults && msg.includes('(@ref)')) {
  222. msg = msg.replace(/\[\d+\]\(@ref\)/g, '').trim();
  223. }
  224. markdown += `${msg}\n\n`;
  225. break;
  226. }
  227. case 'think':
  228. if (settings.exportThinkProcess && block.content) {
  229. markdown += formatThinkContent(block.content, settings);
  230. }
  231. break;
  232. case 'searchGuid': {
  233. let text = applyFormulaReplacements(block.msg || block.content || '', settings.replaceFormulas);
  234. if (settings.keepSearchResults) {
  235. let blockScopedRefStartIndex = refsContext.nextRefIdx;
  236. if (block.docs && block.docs.length > 0) {
  237. block.docs.forEach(doc => {
  238. refsContext.refsArray.push({
  239. idx: refsContext.nextRefIdx++,
  240. title: doc.title || '无标题',
  241. url: doc.url || '#'
  242. });
  243. });
  244. }
  245. let currentRefPlaceholderIdx = blockScopedRefStartIndex;
  246. text = text.replace(/\[(\d+)\]\(@ref\)/g, () => `[^${currentRefPlaceholderIdx++}]`);
  247. markdown += text + '\n\n';
  248. } else if (text) {
  249. text = text.replace(/\[\d+\]\(@ref\)/g, '').trim();
  250. markdown += text + '\n\n';
  251. }
  252. break;
  253. }
  254. case 'image':
  255. case 'code':
  256. case 'pdf':
  257. markdown += `[${block.fileName || '未知文件'}](${block.url || '#'})\n\n`;
  258. break;
  259. }
  260. return markdown;
  261. }
  262.  
  263. function processSpeech(speech, settings, refsContext) {
  264. let markdown = '';
  265. if (speech.content && speech.content.length > 0) {
  266. speech.content.forEach(block => {
  267. markdown += processContentBlock(block, settings, refsContext);
  268. });
  269. }
  270. return markdown;
  271. }
  272.  
  273. function extractUserMessageAndMedia(turn, settings) {
  274. let userTextMsg = '';
  275. let mediaMarkdown = '';
  276.  
  277. if (turn.speechesV2 && turn.speechesV2.length > 0 && turn.speechesV2[0].content) {
  278. const textBlock = turn.speechesV2[0].content.find(block => block.type === 'text');
  279. if (textBlock && typeof textBlock.msg === 'string') {
  280. userTextMsg = textBlock.msg;
  281. }
  282. let uploadedMedia = [];
  283. turn.speechesV2[0].content.forEach(block => {
  284. if (block.type !== 'text' && block.fileName && block.url) {
  285. uploadedMedia.push(`[${block.fileName || '未知文件'}](${block.url || '#'})`);
  286. }
  287. });
  288. if (uploadedMedia.length > 0) {
  289. mediaMarkdown = `\n${uploadedMedia.join('\n')}\n`;
  290. }
  291. }
  292. // Fallback to displayPrompt if text message is still empty
  293. if (!userTextMsg && typeof turn.displayPrompt === 'string') {
  294. userTextMsg = turn.displayPrompt;
  295. }
  296.  
  297. userTextMsg = applyFormulaReplacements(userTextMsg, settings.replaceFormulas);
  298. return (userTextMsg + '\n' + mediaMarkdown).trim() + '\n';
  299. }
  300.  
  301. function processTurnToMarkdown(turn, settings, refsContext, isFullExportContext = false) {
  302. let markdown = '';
  303. if (turn.speaker === 'human') {
  304. if (isFullExportContext) {
  305. markdown += (settings.headerDowngrade ? '> ## user\n' : '> # user\n');
  306. }
  307. markdown += extractUserMessageAndMedia(turn, settings);
  308. } else if (turn.speaker === 'ai') {
  309. if (isFullExportContext) {
  310. markdown += (settings.headerDowngrade ? '> ## agent\n' : '> # agent\n');
  311. }
  312. if (turn.speechesV2 && turn.speechesV2.length > 0) {
  313. turn.speechesV2.forEach(speech => {
  314. markdown += processSpeech(speech, settings, refsContext);
  315. });
  316. }
  317. }
  318. return markdown;
  319. }
  320.  
  321. function convertSingleTurnJsonToMarkdown(jsonData, targetTurnIndex, settings) {
  322. if (!jsonData || !jsonData.convs || !Array.isArray(jsonData.convs)) {
  323. return '# 错误:无效的JSON数据\n\n无法解析对话内容。';
  324. }
  325. const turn = jsonData.convs.find(t => t.index === targetTurnIndex);
  326. if (!turn) return '';
  327.  
  328. let refsContext = { refsArray: [], nextRefIdx: 1 };
  329. let markdownContent = processTurnToMarkdown(turn, settings, refsContext, false);
  330.  
  331. if (settings.keepSearchResults && refsContext.refsArray.length > 0) {
  332. markdownContent += '\n';
  333. refsContext.refsArray.forEach(ref => {
  334. markdownContent += `[^${ref.idx}]: [${ref.title}](${ref.url})\n`;
  335. });
  336. }
  337. if (settings.headerDowngrade) {
  338. markdownContent = adjustHeaderLevels(markdownContent);
  339. }
  340. return markdownContent.trim();
  341. }
  342.  
  343. function convertAllTurnsJsonToMarkdown(jsonData, settings) {
  344. if (!jsonData || !Array.isArray(jsonData.convs)) {
  345. return '# 错误:无效的JSON数据\n\n无法解析对话内容。';
  346. }
  347. let markdownContent = '';
  348. let refsContext = { refsArray: [], nextRefIdx: 1 };
  349.  
  350. // jsonData.convs is newest first. Reverse to process oldest first for chronological output.
  351. jsonData.convs.slice().reverse().forEach(turn => {
  352. markdownContent += processTurnToMarkdown(turn, settings, refsContext, true);
  353. });
  354.  
  355. if (settings.keepSearchResults && refsContext.refsArray.length > 0) {
  356. markdownContent += '\n';
  357. refsContext.refsArray.forEach(ref => {
  358. markdownContent += `[^${ref.idx}]: [${ref.title}](${ref.url})\n`;
  359. });
  360. }
  361. if (settings.headerDowngrade) {
  362. // Note: Speaker headers are already downgraded by processTurnToMarkdown if isFullExportContext.
  363. // This call will downgrade any other headers within the content.
  364. markdownContent = adjustHeaderLevels(markdownContent);
  365. }
  366. return markdownContent.trim();
  367. }
  368.  
  369.  
  370. // --- UI Injection & Event Handlers ---
  371. function createStyledButton(text, onclick, iconSvg = '', customStyles = {}) {
  372. const btn = document.createElement('button');
  373. btn.type = 'button';
  374. btn.innerHTML = iconSvg + text;
  375. Object.assign(btn.style, BTN_STYLE, customStyles); // Base style, then specific overrides
  376. btn.onmouseover = () => { btn.style.background = BTN_STYLE.backgroundHover; };
  377. btn.onmouseout = () => { btn.style.background = BTN_STYLE.background; };
  378. btn.onclick = onclick;
  379. // 确保初始状态下背景色正确
  380. btn.style.background = BTN_STYLE.background;
  381. return btn;
  382. }
  383.  
  384. function injectCopyButtonToBubble(copyBtnElement, allBubbles, jsonData) {
  385. if (copyBtnElement.parentElement.querySelector('.agent-chat__toolbar__copy-md')) return;
  386.  
  387. const mdBtn = createStyledButton('复制MD', null, '', { fontSize: '14px' }); // Use shared styling
  388. mdBtn.title = '复制Markdown(接口数据)';
  389. mdBtn.className = 'agent-chat__toolbar__copy-md';
  390.  
  391. mdBtn.onclick = function (e) {
  392. e.stopPropagation();
  393. const bubble = copyBtnElement.closest('.agent-chat__bubble');
  394. if (!bubble) { alert('未找到对话泡'); return; }
  395.  
  396. const domIdx = allBubbles.indexOf(bubble);
  397. const jsonConvs = jsonData && Array.isArray(jsonData.convs) ? jsonData.convs : [];
  398. // Assuming jsonData.convs is newest first, and allBubbles is oldest first.
  399. const jsonTargetIdx = jsonConvs.length - 1 - domIdx;
  400. let targetTurnUniqueIndex = null;
  401. if (jsonConvs[jsonTargetIdx]) {
  402. targetTurnUniqueIndex = jsonConvs[jsonTargetIdx].index;
  403. }
  404.  
  405. if (targetTurnUniqueIndex === null || targetTurnUniqueIndex === undefined) {
  406. alert('无法匹配到正确的对话轮次');
  407. return;
  408. }
  409.  
  410. const settings = getSettings();
  411. const md = convertSingleTurnJsonToMarkdown(jsonData, targetTurnUniqueIndex, settings);
  412. if (!md) { alert('未提取到Markdown内容'); return; }
  413.  
  414. navigator.clipboard.writeText(md).then(() => {
  415. mdBtn.title = '已复制!';
  416. mdBtn.style.opacity = 0.5;
  417. setTimeout(() => {
  418. mdBtn.title = '复制Markdown(接口数据)';
  419. mdBtn.style.opacity = 1;
  420. }, 1000);
  421. }).catch(err => {
  422. console.error(`${SCRIPT_NAME}: Failed to copy: `, err);
  423. alert('复制失败,详情请查看控制台。');
  424. });
  425. };
  426. copyBtnElement.parentElement.insertBefore(mdBtn, copyBtnElement.nextSibling);
  427. }
  428.  
  429. function injectCopyButtonsToAllBubbles() {
  430. const settings = getSettings();
  431. if (!settings.autoInjectCopyBtn || !state.latestDetailResponse) return;
  432.  
  433. let jsonData;
  434. try {
  435. jsonData = JSON.parse(state.latestDetailResponse);
  436. } catch (e) {
  437. console.error(`${SCRIPT_NAME}: JSON parsing failed for injecting copy buttons.`, e);
  438. // Do not alert here as this runs frequently.
  439. return;
  440. }
  441. if (!jsonData || !jsonData.convs) return;
  442.  
  443. const allBubbles = Array.from(document.querySelectorAll('.agent-chat__bubble'));
  444. document.querySelectorAll('.agent-chat__toolbar__copy').forEach(copyBtn => {
  445. injectCopyButtonToBubble(copyBtn, allBubbles, jsonData);
  446. });
  447. }
  448.  
  449. function injectExportButtonsToToolbar(toolbarElement) {
  450. if (!toolbarElement || toolbarElement.dataset.mdInjected) return;
  451. toolbarElement.innerHTML = ''; // Clear existing content
  452. Object.assign(toolbarElement.style, {
  453. display: 'flex', gap: '4px', width: '150px', alignItems: 'center', height: '34px'
  454. });
  455.  
  456. const settings = getSettings();
  457.  
  458. const btnAllOnClick = () => {
  459. if (!state.latestDetailResponse) {
  460. alert('未捕获到对话数据,请刷新页面或重新进入对话。');
  461. return;
  462. }
  463. let jsonData;
  464. try {
  465. jsonData = JSON.parse(state.latestDetailResponse);
  466. } catch (e) { alert('JSON 解析失败'); return; }
  467.  
  468. const md = convertAllTurnsJsonToMarkdown(jsonData, settings);
  469. if (!md) { alert('未提取到Markdown内容'); return; }
  470. navigator.clipboard.writeText(md).then(() => {
  471. alert('全部对话已复制到剪贴板!');
  472. }).catch(err => {
  473. console.error(`${SCRIPT_NAME}: Failed to copy all: `, err);
  474. alert('复制失败,详情请查看控制台。');
  475. });
  476. };
  477.  
  478. const btnAll = createStyledButton('全部', btnAllOnClick, ICON_EXPORT_ALL, EXPORT_BTN_STYLE);
  479. const btnDialogue = createStyledButton('对话', injectCopyButtonsToAllBubbles, ICON_DIALOGUE, EXPORT_BTN_STYLE);
  480.  
  481. toolbarElement.appendChild(btnAll);
  482. toolbarElement.appendChild(btnDialogue);
  483. toolbarElement.dataset.mdInjected = '1';
  484. }
  485.  
  486. // --- DOM Observation ---
  487. function observeDOMChanges() {
  488. const observer = new MutationObserver((mutationsList) => {
  489. for (const mutation of mutationsList) {
  490. if (mutation.type === 'childList') {
  491. mutation.addedNodes.forEach(node => {
  492. if (node.nodeType === 1) {
  493. if (node.classList && node.classList.contains('agent-dialogue__tool')) {
  494. injectExportButtonsToToolbar(node);
  495. } else if (node.querySelectorAll) {
  496. node.querySelectorAll('.agent-dialogue__tool').forEach(el => injectExportButtonsToToolbar(el));
  497. }
  498. }
  499. });
  500. }
  501. }
  502. // Always try to inject copy buttons if new nodes are added and auto-inject is on
  503. if (getSettings().autoInjectCopyBtn) {
  504. injectCopyButtonsToAllBubbles();
  505. }
  506. });
  507. observer.observe(document.body, { childList: true, subtree: true });
  508. }
  509.  
  510. // --- Main Initialization ---
  511. function init() {
  512. setupNetworkInterceptors();
  513. observeDOMChanges();
  514. // Initial call to inject buttons if content is already present
  515. if (getSettings().autoInjectCopyBtn) {
  516. injectCopyButtonsToAllBubbles();
  517. }
  518. // Attempt to inject toolbar buttons if already present
  519. const existingToolbar = document.querySelector('.agent-dialogue__tool');
  520. if (existingToolbar) {
  521. injectExportButtonsToToolbar(existingToolbar);
  522. }
  523. }
  524.  
  525. // --- Script Execution ---
  526. if (typeof GM_registerMenuCommand === 'function') {
  527. GM_registerMenuCommand('脚本设置', showSettingsDialog);
  528. }
  529.  
  530. if (document.readyState === 'loading') {
  531. window.addEventListener('DOMContentLoaded', init);
  532. } else {
  533. init();
  534. }
  535. })();