// ==UserScript== // @name ChatGPT一键导出聊天到obsidian // @namespace https://chatgpt.com/ // @version 1.0.0 // @description 在 ChatGPT 网页中一键导出当前会话到 Obsidian,或下载为 Markdown。消息内容通过网页“复制消息/复制回复”按钮获取。 // @author OpenAI // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_notification // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const STORE_PREFIX = 'chatgpt_export_obsidian_'; const BTN_OBSIDIAN_ID = 'chatgpt-export-obsidian-btn'; const BTN_MD_ID = 'chatgpt-download-markdown-btn'; const TOAST_ID = 'chatgpt-export-obsidian-toast'; const DEFAULT_FOLDER = 'ChatGPT'; // ---------- 存储 ---------- async function getValue(key, defaultValue) { try { if (typeof GM_getValue === 'function') { return await Promise.resolve(GM_getValue(key, defaultValue)); } } catch (_) {} try { const raw = localStorage.getItem(STORE_PREFIX + key); return raw == null ? defaultValue : JSON.parse(raw); } catch (_) { return defaultValue; } } async function setValue(key, value) { try { if (typeof GM_setValue === 'function') { return await Promise.resolve(GM_setValue(key, value)); } } catch (_) {} try { localStorage.setItem(STORE_PREFIX + key, JSON.stringify(value)); } catch (_) {} } // ---------- UI 优化版 (修复 notify 未定义问题) ---------- function toast(message, type = 'info', duration = 2800) { let el = document.getElementById(TOAST_ID); if (!el) { el = document.createElement('div'); el.id = TOAST_ID; document.body.appendChild(el); Object.assign(el.style, { position: 'fixed', left: '50%', bottom: '85px', transform: 'translateX(-50%) translateY(20px)', zIndex: '1000000', padding: '12px 20px', borderRadius: '12px', color: '#fff', fontSize: '14px', fontWeight: '500', boxShadow: '0 10px 25px -5px rgba(0,0,0,0.4)', opacity: '0', pointerEvents: 'none', transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)', backdropFilter: 'blur(8px)', display: 'flex', alignItems: 'center', gap: '8px' }); } const colors = { info: 'rgba(31, 41, 55, 0.95)', success: 'rgba(16, 185, 129, 0.95)', error: 'rgba(239, 68, 68, 0.95)', warn: 'rgba(245, 158, 11, 0.95)' }; el.textContent = (type === 'success' ? '✓ ' : '') + message; el.style.background = colors[type] || colors.info; el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; clearTimeout(el._timer); el._timer = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(20px)'; }, duration); } // 重新补回缺失的 notify 函数 [cite: 14] function notify(text) { if (typeof GM_notification === 'function') { try { GM_notification({ title: 'ChatGPT 导出', text }); } catch (_) {} } } function buildMiniButton({ id, text, bg, icon }) { let btn = document.getElementById(id); if (btn) return btn; btn = document.createElement('button'); btn.id = id; btn.type = 'button'; // 结构优化:确保收缩时图标完美居中 btn.innerHTML = `
${icon || '•'}
${text} `; Object.assign(btn.style, { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', width: '44px', height: '44px', padding: '0', borderRadius: '22px', background: bg, color: '#fff', border: '1px solid rgba(255,255,255,0.1)', cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', overflow: 'hidden', outline: 'none' }); btn.addEventListener('mouseenter', () => { if (!btn.disabled) { btn.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)'; btn.style.transform = 'translateY(-2px)'; btn.style.filter = 'brightness(1.1)'; } }); btn.addEventListener('mouseleave', () => { if (!btn.disabled) { btn.style.transform = 'translateY(0)'; btn.style.filter = 'brightness(1)'; } }); return btn; } function ensureButtons() { if (!document.body) return; let wrap = document.getElementById('chatgpt-export-floating-wrap'); if (!wrap) { wrap = document.createElement('div'); wrap.id = 'chatgpt-export-floating-wrap'; Object.assign(wrap.style, { position: 'fixed', right: '24px', bottom: '24px', zIndex: '999999', display: 'flex', flexDirection: 'column-reverse', alignItems: 'flex-end', gap: '12px', padding: '4px' }); document.body.appendChild(wrap); } const mdBtn = buildMiniButton({ id: BTN_MD_ID, text: '下载 Markdown', bg: '#374151', icon: '📥' }); const obsidianBtn = buildMiniButton({ id: BTN_OBSIDIAN_ID, text: '导出到 Obsidian', bg: '#6c35b8', icon: '💎' }); if (!mdBtn.parentElement) wrap.appendChild(mdBtn); if (!obsidianBtn.parentElement) wrap.appendChild(obsidianBtn); function setExpanded(btn, expanded) { const textEl = btn.querySelector('.cgpt-btn-text'); if (!textEl) return; if (expanded) { btn.style.width = '170px'; btn.style.paddingRight = '16px'; textEl.style.opacity = '1'; textEl.style.width = 'auto'; textEl.style.marginLeft = '4px'; } else { btn.style.width = '44px'; btn.style.paddingRight = '0'; textEl.style.opacity = '0'; textEl.style.width = '0px'; textEl.style.marginLeft = '0'; } } if (!wrap.dataset.boundHover) { wrap.dataset.boundHover = '1'; wrap.addEventListener('mouseenter', () => { [mdBtn, obsidianBtn].forEach(b => setExpanded(b, true)); }); wrap.addEventListener('mouseleave', () => { [mdBtn, obsidianBtn].forEach(b => setExpanded(b, false)); }); } if (!obsidianBtn.dataset.bound) { obsidianBtn.dataset.bound = '1'; obsidianBtn.addEventListener('click', exportToObsidian); } if (!mdBtn.dataset.bound) { mdBtn.dataset.bound = '1'; mdBtn.addEventListener('click', downloadCurrentChatMarkdown); } } function setButtonBusy(btn, busy, busyText, normalText) { if (!btn) return; const textEl = btn.querySelector('.cgpt-btn-text'); const iconEl = btn.querySelector('.cgpt-btn-icon'); btn.disabled = !!busy; btn.style.opacity = busy ? '0.7' : '1'; btn.style.cursor = busy ? 'not-allowed' : 'pointer'; if (textEl && normalText && busyText) textEl.textContent = busy ? busyText : normalText; if (busy) { iconEl.style.animation = 'cgpt-spin 1s linear infinite'; if (!document.getElementById('cgpt-style-spin')) { const style = document.createElement('style'); style.id = 'cgpt-style-spin'; style.textContent = '@keyframes cgpt-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'; document.head.appendChild(style); } } else { iconEl.style.animation = 'none'; } } // ---------- 配置 ---------- async function configure(force = false) { let vault = await getValue('vault', ''); let folder = await getValue('folder', DEFAULT_FOLDER); if (force || !vault) { vault = (prompt('请输入你的 Obsidian Vault 名称(必须和 Obsidian 中显示的一致)', vault || '') || '').trim(); if (!vault) throw new Error('未设置 Vault 名称'); await setValue('vault', vault); } if (force || folder == null) { const nextFolder = prompt('请输入导出目录(留空表示导出到根目录)', folder || DEFAULT_FOLDER); folder = (nextFolder == null ? folder : nextFolder).trim(); await setValue('folder', folder); } return { vault, folder }; } // ---------- 工具 ---------- function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function pad2(n) { return String(n).padStart(2, '0'); } function formatLocalDateTime(d = new Date()) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } function formatCompactDateTime(d = new Date()) { return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`; } function getConversationId() { const m = location.pathname.match(/\/c\/([a-zA-Z0-9-]+)/); return m ? m[1] : ''; } function getConversationTitle() { const raw = document.title || 'ChatGPT 对话'; return raw.replace(/\s*-\s*ChatGPT\s*$/i, '').trim() || 'ChatGPT 对话'; } function sanitizeSegment(name) { return String(name || '') .replace(/[<>:"\\|?*\u0000-\u001F]/g, ' ') .replace(/\s+/g, ' ') .replace(/\.+$/g, '') .trim() .slice(0, 120) || 'untitled'; } function sanitizeFolderPath(folder) { return String(folder || '') .split('/') .map(s => sanitizeSegment(s)) .filter(Boolean) .join('/'); } function yamlString(value) { return JSON.stringify(String(value ?? '')); } function cleanWhitespace(text) { return String(text || '') .replace(/\r/g, '') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } async function readClipboardSafe() { try { return await navigator.clipboard.readText(); } catch (_) { return ''; } } async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (_) {} try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); Object.assign(ta.style, { position: 'fixed', top: '-1000px', left: '-1000px', opacity: '0' }); document.body.appendChild(ta); ta.focus(); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return !!ok; } catch (_) { return false; } } function downloadMarkdown(filename, content) { const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename.endsWith('.md') ? filename : `${filename}.md`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); } function openObsidian(vault, filePath, useClipboard = true, content = '') { const params = new URLSearchParams(); params.set('vault', vault); params.set('file', filePath); params.set('overwrite', 'true'); if (useClipboard) { params.set('clipboard', 'true'); } else { params.set('content', content); } const url = `obsidian://new?${params.toString()}`; window.location.href = url; } // ---------- DOM -> Markdown 兜底 ---------- function shouldIgnoreNode(node) { if (!node || node.nodeType !== 1) return false; const el = node; const tag = el.tagName.toLowerCase(); if (['button', 'svg', 'path', 'textarea', 'input', 'select', 'option', 'nav', 'noscript', 'script', 'style'].includes(tag)) { return true; } if (el.getAttribute('aria-hidden') === 'true') return true; if (el.classList.contains('sr-only')) return true; return false; } function detectCodeLang(el) { const code = el.querySelector('code') || el; const className = `${code.className || ''} ${el.className || ''}`; const m = className.match(/language-([a-z0-9_+-]+)/i); return m ? m[1] : ''; } function serializeInlineChildren(node) { return Array.from(node.childNodes).map(serializeNode).join(''); } function serializeTable(table) { const rows = Array.from(table.querySelectorAll('tr')).map(tr => { return Array.from(tr.children).map(td => { const cell = cleanWhitespace(serializeInlineChildren(td)) .replace(/\n/g, '
') .replace(/\|/g, '\\|'); return cell || ' '; }); }); if (!rows.length) return ''; const header = rows[0]; const sep = header.map(() => '---'); const lines = [ `| ${header.join(' | ')} |`, `| ${sep.join(' | ')} |`, ...rows.slice(1).map(r => `| ${r.join(' | ')} |`) ]; return lines.join('\n'); } function serializeList(listEl, depth = 0, ordered = false) { const items = Array.from(listEl.children).filter(li => li.tagName && li.tagName.toLowerCase() === 'li'); return items.map((li, i) => serializeListItem(li, depth, ordered, i)).join('\n'); } function serializeListItem(li, depth, ordered, index) { const indent = ' '.repeat(depth); const bullet = ordered ? `${index + 1}. ` : '- '; let main = ''; let nested = ''; for (const child of Array.from(li.childNodes)) { if (child.nodeType === 1) { const tag = child.tagName.toLowerCase(); if (tag === 'ul') { nested += '\n' + serializeList(child, depth + 1, false); continue; } if (tag === 'ol') { nested += '\n' + serializeList(child, depth + 1, true); continue; } } main += serializeNode(child); } main = cleanWhitespace(main).replace(/\n/g, '\n' + indent + ' '); return `${indent}${bullet}${main}${nested}`; } function serializeNode(node) { if (!node) return ''; if (node.nodeType === Node.TEXT_NODE) { return node.nodeValue || ''; } if (node.nodeType !== Node.ELEMENT_NODE) { return ''; } if (shouldIgnoreNode(node)) { return ''; } const el = node; const tag = el.tagName.toLowerCase(); switch (tag) { case 'br': return '\n'; case 'hr': return '\n---\n\n'; case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': { const level = Number(tag[1]); const text = cleanWhitespace(serializeInlineChildren(el)); return `${'#'.repeat(level)} ${text}\n\n`; } case 'p': { const text = cleanWhitespace(serializeInlineChildren(el)); return text ? `${text}\n\n` : ''; } case 'strong': case 'b': { const text = cleanWhitespace(serializeInlineChildren(el)); return text ? `**${text}**` : ''; } case 'em': case 'i': { const text = cleanWhitespace(serializeInlineChildren(el)); return text ? `*${text}*` : ''; } case 'del': case 's': { const text = cleanWhitespace(serializeInlineChildren(el)); return text ? `~~${text}~~` : ''; } case 'code': { if (el.closest('pre')) return ''; return '`' + (el.textContent || '').replace(/`/g, '\\`') + '`'; } case 'pre': { const codeEl = el.querySelector('code') || el; const lang = detectCodeLang(el); const code = (codeEl.textContent || '').replace(/\n+$/g, ''); return `\`\`\`${lang}\n${code}\n\`\`\`\n\n`; } case 'blockquote': { const inner = cleanWhitespace(serializeInlineChildren(el)); if (!inner) return ''; return inner.split('\n').map(line => `> ${line}`).join('\n') + '\n\n'; } case 'a': { const href = el.getAttribute('href') || ''; const text = cleanWhitespace(serializeInlineChildren(el)) || href; if (!href) return text; return `[${text}](${href})`; } case 'img': { const alt = el.getAttribute('alt') || 'image'; const src = el.getAttribute('src') || ''; return src ? `![${alt}](${src})` : `![${alt}]()`; } case 'ul': return serializeList(el, 0, false) + '\n\n'; case 'ol': return serializeList(el, 0, true) + '\n\n'; case 'table': return serializeTable(el) + '\n\n'; case 'details': case 'summary': { const text = cleanWhitespace(serializeInlineChildren(el)); return text ? `${text}\n\n` : ''; } case 'div': case 'section': case 'article': case 'span': case 'main': default: return Array.from(el.childNodes).map(serializeNode).join(''); } } function normalizeMarkdown(md) { return String(md || '') .replace(/\r/g, '') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]{2,}/g, ' ') .trim(); } function findContentRoot(container) { const selectors = [ '[data-message-content]', '.markdown', '[class*="markdown"]', '.prose', '[class*="prose"]', '[class*="whitespace-pre-wrap"]' ]; for (const sel of selectors) { const found = container.querySelector(sel); if (found && found.textContent && found.textContent.trim()) { return found; } } return container; } // ---------- 优先使用网页复制按钮提取消息 ---------- function roleLabel(role) { return role === 'user' ? '我' : 'ChatGPT'; } function getTurnType(turnEl) { const dataTurn = turnEl.getAttribute('data-turn'); if (dataTurn === 'user') return 'user'; if (turnEl.querySelector('[data-message-author-role="user"]')) return 'user'; if (turnEl.querySelector('button[aria-label="复制回复"]')) return 'assistant'; if (turnEl.querySelector('button[aria-label="Copy response"]')) return 'assistant'; if (turnEl.querySelector('button[aria-label="复制消息"]')) return 'user'; if (turnEl.querySelector('button[aria-label="Copy prompt"]')) return 'user'; return ''; } function findCopyButton(turnEl, type) { if (type === 'user') { return ( turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="复制消息"]') || turnEl.querySelector('button[aria-label="复制消息"]') || turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="Copy prompt"]') || turnEl.querySelector('button[aria-label="Copy prompt"]') ); } if (type === 'assistant') { return ( turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="复制回复"]') || turnEl.querySelector('button[aria-label="复制回复"]') || turnEl.querySelector('button[data-testid="copy-turn-action-button"][aria-label="Copy response"]') || turnEl.querySelector('button[aria-label="Copy response"]') ); } return null; } function extractFallbackFromTurn(turnEl, role) { if (role === 'user') { const userBlock = turnEl.querySelector('[data-message-author-role="user"]'); if (userBlock && userBlock.innerText && userBlock.innerText.trim()) { return userBlock.innerText.trim(); } } const root = findContentRoot(turnEl); const md = normalizeMarkdown(serializeNode(root)); if (md) return md; return (turnEl.innerText || '').trim(); } async function clickAndReadClipboard(copyBtn, fallbackText = '') { const before = await readClipboardSafe(); try { copyBtn.scrollIntoView({ behavior: 'instant', block: 'center' }); } catch (_) { copyBtn.scrollIntoView({ block: 'center' }); } const hoverTarget = copyBtn.closest('[data-testid^="conversation-turn-"], section, article, div'); if (hoverTarget) { hoverTarget.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); hoverTarget.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); hoverTarget.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })); } await sleep(80); copyBtn.click(); await sleep(300); for (let i = 0; i < 8; i++) { const after = await readClipboardSafe(); if (after && after !== before) return after; await sleep(160); } return fallbackText || ''; } async function extractMessagesUsingCopyButtons(progressCb) { const turnNodes = Array.from(document.querySelectorAll('section[data-testid^="conversation-turn-"]')); if (!turnNodes.length) return []; const messages = []; for (let i = 0; i < turnNodes.length; i++) { const turnEl = turnNodes[i]; const role = getTurnType(turnEl); if (!role) continue; progressCb && progressCb(i + 1, turnNodes.length, role); const copyBtn = findCopyButton(turnEl, role); const fallbackText = extractFallbackFromTurn(turnEl, role); let content = ''; if (copyBtn) { content = await clickAndReadClipboard(copyBtn, fallbackText); } else { content = fallbackText; } content = normalizeMarkdown(content); if (!content) continue; messages.push({ role, content }); } return messages; } function extractMessagesFallback() { const explicitNodes = Array.from(document.querySelectorAll('[data-message-author-role]')) .filter(el => !el.parentElement || !el.parentElement.closest('[data-message-author-role]')); if (explicitNodes.length) { const msgs = explicitNodes.map(el => { const rawRole = (el.getAttribute('data-message-author-role') || '').toLowerCase(); const role = rawRole.includes('user') ? 'user' : 'assistant'; const root = findContentRoot(el); const content = normalizeMarkdown(serializeNode(root)); return { role, content }; }).filter(m => m.content); if (msgs.length) return msgs; } const articles = Array.from(document.querySelectorAll('main article')); if (!articles.length) return []; let expected = 'user'; const result = []; for (const article of articles) { let role = expected; const copyBtn = article.querySelector('button[aria-label*="Copy"], button[aria-label*="复制"], [data-testid*="copy"]'); const editBtn = article.querySelector('button[aria-label*="Edit"], button[aria-label*="编辑"]'); if (copyBtn) role = 'assistant'; else if (editBtn) role = 'user'; expected = role === 'user' ? 'assistant' : 'user'; const root = findContentRoot(article); const content = normalizeMarkdown(serializeNode(root)); if (!content) continue; result.push({ role, content }); } return result; } async function extractMessages(progressCb) { const byCopy = await extractMessagesUsingCopyButtons(progressCb); if (byCopy.length) return byCopy; return extractMessagesFallback(); } // ---------- Markdown 构建 ---------- function buildMarkdown(title, url, conversationId, messages) { const exportedAt = formatLocalDateTime(new Date()); const frontmatter = [ '---', `title: ${yamlString(title)}`, `source: ${yamlString(url)}`, `conversation_id: ${yamlString(conversationId || 'unknown')}`, `exported_at: ${yamlString(exportedAt)}`, 'tags:', ' - chatgpt', ' - export', '---', '' ].join('\n'); const header = [ `# ${title}`, '', `> 导出自 ChatGPT 网页`, `> 原始链接:${url}`, `> 导出时间:${exportedAt}`, '' ].join('\n'); const body = messages.map((msg, idx) => { return `## ${idx + 1}. ${roleLabel(msg.role)}\n\n${msg.content || '_(空)_'}\n`; }).join('\n'); return `${frontmatter}${header}${body}`.trim() + '\n'; } async function collectCurrentChatMarkdown(progressCb) { const messages = await extractMessages(progressCb); if (!messages.length) { throw new Error('没提取到聊天内容,请先打开一个具体会话页面。'); } const title = getConversationTitle(); const conversationId = getConversationId(); const markdown = buildMarkdown(title, location.href, conversationId, messages); return { title, conversationId, messages, markdown }; } // ---------- 导出动作 ---------- async function exportToObsidian() { const btn = document.getElementById(BTN_OBSIDIAN_ID); try { setButtonBusy(btn, true, '导出中…', '导出到 Obsidian'); const { vault, folder } = await configure(false); const { title, conversationId, markdown } = await collectCurrentChatMarkdown((i, total, role) => { toast(`正在提取第 ${i}/${total} 条${role === 'user' ? '消息' : '回复'}…`, 'info', 900); }); const safeTitle = sanitizeSegment(title); const safeFolder = sanitizeFolderPath(folder); const stableName = `${safeTitle}.md`; const filePath = safeFolder ? `${safeFolder}/${stableName}` : stableName; const copied = await copyText(markdown); if (copied) { openObsidian(vault, filePath, true); toast(`已导出到 Obsidian:${filePath}`, 'success', 3600); notify(`已导出到 Obsidian:${filePath}`); return; } if (markdown.length < 1800) { openObsidian(vault, filePath, false, markdown); toast(`剪贴板不可用,已改用内容直传:${filePath}`, 'success', 3600); notify(`已导出到 Obsidian:${filePath}`); return; } downloadMarkdown(stableName, markdown); toast('导出到 Obsidian 失败:剪贴板不可用,已下载 Markdown 作为兜底。', 'warn', 4200); } catch (err) { console.error('[ChatGPT -> Obsidian]', err); toast(`导出失败:${err.message || err}`, 'error', 3600); } finally { setButtonBusy(btn, false, '导出中…', '导出到 Obsidian'); } } async function downloadCurrentChatMarkdown() { const btn = document.getElementById(BTN_MD_ID); try { setButtonBusy(btn, true, '下载中…', '下载 Markdown'); const { title, conversationId, markdown } = await collectCurrentChatMarkdown((i, total, role) => { toast(`正在整理第 ${i}/${total} 条${role === 'user' ? '消息' : '回复'}…`, 'info', 900); }); const safeTitle = sanitizeSegment(title); const stableName = `${safeTitle}.md`; downloadMarkdown(stableName, markdown); toast(`已下载 Markdown:${stableName}`, 'success', 3200); notify(`已下载 Markdown:${stableName}`); } catch (err) { console.error('[ChatGPT -> Markdown]', err); toast(`下载失败:${err.message || err}`, 'error', 3600); } finally { setButtonBusy(btn, false, '下载中…', '下载 Markdown'); } } // ---------- 菜单 ---------- function registerMenu() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('设置 Obsidian Vault / 文件夹', async () => { try { await configure(true); toast('配置已保存。', 'success'); } catch (err) { toast(`配置未完成:${err.message || err}`, 'warn'); } }); GM_registerMenuCommand('导出当前会话到 Obsidian', exportToObsidian); GM_registerMenuCommand('下载当前会话 Markdown', downloadCurrentChatMarkdown); } // ---------- 启动 ---------- function init() { ensureButtons(); registerMenu(); setInterval(() => { ensureButtons(); }, 1500); } init(); })();