// ==UserScript==
// @name 豆包/元宝 快捷输入与对话导出悬浮球
// @namespace http://tampermonkey.net/
// @version 1.2
// @description 在豆包 / 腾讯元宝网页端添加悬浮球:一个用于快捷输入,另一个一键把当前对话导出为 Markdown 文件
// @author Your Name
// @match https://www.doubao.com/*
// @match https://doubao.com/*
// @match https://yuanbao.tencent.com/*
// @icon https://www.doubao.com/favicon.ico
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 默认快捷语句
const defaultQuickInputs = [
'你好,有什么可以帮助我的吗?',
'请详细解释这个概念',
'举个例子说明一下',
'还有其他方法吗?',
'谢谢,我明白了'
];
// ===================== 站点配置 =====================
// 不同平台 DOM 结构不同,抽取逻辑全部从这里取,便于后续扩展新平台
const SITES = {
doubao: {
label: '豆包',
test: (host) => host.includes('doubao.com'),
msgRow: ['div.v_list_row', '[data-testid="message-block-container"]', '[data-role="message"]'],
isUser: (row) => {
if (row.querySelector('[data-target-id*="message-box-target-id"]')) return true;
if (row.querySelector('textarea, [contenteditable="true"]')) return true;
return /(^|\s)user(\s|$)/i.test(row.className);
},
isAssistant: (row) => {
if (row.querySelector('[data-copy-telemetry="right_click_copy"]')) return true;
return /(^|\s)(assistant|bot|ai)(\s|$)/i.test(row.className);
},
contentEl: (row) => row.querySelector('div[data-testid="message_text_content"]') || row,
loadMore: false
},
yuanbao: {
label: '元宝',
test: (host) => host.includes('yuanbao.tencent.com'),
msgRow: ['.agent-chat__list__item'],
isUser: (row) => row.classList.contains('agent-chat__list__item--human'),
isAssistant: (row) => !row.classList.contains('agent-chat__list__item--human'),
contentEl: (row) => {
if (row.classList.contains('agent-chat__list__item--human')) {
return row.querySelector('.hyc-content-text') || row;
}
return row.querySelector('.hyc-content-md') || row;
},
loadMore: true
}
};
// 根据当前域名确定使用哪套配置
function getSiteConfig() {
const host = location.hostname;
for (const key in SITES) {
if (SITES[key].test(host)) return SITES[key];
}
return SITES.doubao; // 默认按豆包处理
}
// ===================== 快捷输入相关 =====================
// 从本地存储获取快捷语句,若不存在则使用默认值
function getQuickInputs() {
const saved = localStorage.getItem('doubaoQuickInputs');
return saved ? JSON.parse(saved) : defaultQuickInputs;
}
// 保存快捷语句到本地存储
function saveQuickInputs(inputs) {
localStorage.setItem('doubaoQuickInputs', JSON.stringify(inputs));
}
// 创建快捷输入悬浮球元素
function createFloatingBall() {
const ball = document.createElement('div');
ball.id = 'doubao-floating-ball';
ball.style.position = 'fixed';
ball.style.right = '20px';
ball.style.top = '50%';
ball.style.transform = 'translateY(-50%)';
ball.style.width = '60px';
ball.style.height = '60px';
ball.style.borderRadius = '50%';
ball.style.backgroundColor = '#1677ff';
ball.style.color = 'white';
ball.style.display = 'flex';
ball.style.alignItems = 'center';
ball.style.justifyContent = 'center';
ball.style.fontSize = '24px';
ball.style.cursor = 'pointer';
ball.style.zIndex = '9999';
ball.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
ball.style.transition = 'all 0.3s ease';
ball.innerHTML = '💬';
// 鼠标悬停效果
ball.addEventListener('mouseenter', function() {
ball.style.transform = 'translateY(-50%) scale(1.1)';
});
ball.addEventListener('mouseleave', function() {
ball.style.transform = 'translateY(-50%) scale(1)';
});
return ball;
}
// 创建快捷输入面板
function createQuickInputPanel() {
const panel = document.createElement('div');
panel.id = 'doubao-quick-input-panel';
panel.style.position = 'fixed';
panel.style.right = '90px';
panel.style.top = '50%';
panel.style.transform = 'translateY(-50%)';
panel.style.width = '300px';
panel.style.backgroundColor = 'white';
panel.style.borderRadius = '8px';
panel.style.boxShadow = '0 4px 16px rgba(0,0,0,0.15)';
panel.style.zIndex = '9998';
panel.style.padding = '16px';
panel.style.display = 'none';
panel.style.maxHeight = '400px';
panel.style.overflowY = 'auto';
// 面板标题
const title = document.createElement('h3');
title.style.marginTop = '0';
title.style.marginBottom = '16px';
title.style.fontSize = '16px';
title.style.color = '#333';
title.textContent = '快捷输入';
panel.appendChild(title);
// 快捷语句列表
const list = document.createElement('div');
list.id = 'doubao-quick-input-list';
panel.appendChild(list);
// 添加新快捷语句的输入框
const addSection = document.createElement('div');
addSection.style.marginTop = '16px';
addSection.style.paddingTop = '16px';
addSection.style.borderTop = '1px solid #eee';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = '添加新的快捷语句';
input.style.width = '100%';
input.style.padding = '8px';
input.style.border = '1px solid #ddd';
input.style.borderRadius = '4px';
input.style.marginBottom = '8px';
addSection.appendChild(input);
const addButton = document.createElement('button');
addButton.textContent = '添加';
addButton.style.padding = '6px 12px';
addButton.style.backgroundColor = '#1677ff';
addButton.style.color = 'white';
addButton.style.border = 'none';
addButton.style.borderRadius = '4px';
addButton.style.cursor = 'pointer';
addSection.appendChild(addButton);
panel.appendChild(addSection);
// 添加按钮点击事件
addButton.addEventListener('click', function() {
const newInput = input.value.trim();
if (newInput) {
const quickInputs = getQuickInputs();
quickInputs.push(newInput);
saveQuickInputs(quickInputs);
renderQuickInputs();
input.value = '';
}
});
return panel;
}
// 渲染快捷输入语句
function renderQuickInputs() {
const list = document.getElementById('doubao-quick-input-list');
if (!list) return;
list.innerHTML = '';
const quickInputs = getQuickInputs();
quickInputs.forEach((text, index) => {
const item = document.createElement('div');
item.style.padding = '10px';
item.style.borderBottom = '1px solid #f0f0f0';
item.style.cursor = 'pointer';
item.style.display = 'flex';
item.style.justifyContent = 'space-between';
item.style.alignItems = 'center';
const textSpan = document.createElement('span');
textSpan.textContent = text;
textSpan.style.flex = '1';
textSpan.style.wordBreak = 'break-word';
item.appendChild(textSpan);
const deleteButton = document.createElement('button');
deleteButton.textContent = '删除';
deleteButton.style.padding = '4px 8px';
deleteButton.style.backgroundColor = '#ff4d4f';
deleteButton.style.color = 'white';
deleteButton.style.border = 'none';
deleteButton.style.borderRadius = '4px';
deleteButton.style.cursor = 'pointer';
deleteButton.style.fontSize = '12px';
item.appendChild(deleteButton);
// 点击快捷语句,将其输入到聊天框(兼容 textarea 与 contenteditable)
textSpan.addEventListener('click', function() {
const chatInput = document.querySelector('textarea')
|| document.querySelector('[contenteditable="true"]')
|| document.querySelector('[contenteditable=""]');
if (chatInput) {
const tag = chatInput.tagName;
if (tag === 'TEXTAREA' || tag === 'INPUT') {
chatInput.value = text;
} else {
chatInput.innerText = text;
}
// 触发输入事件,确保目标站点能够识别输入
chatInput.dispatchEvent(new Event('input', { bubbles: true }));
}
togglePanel();
});
// 删除快捷语句
deleteButton.addEventListener('click', function(e) {
e.stopPropagation();
const quickInputs = getQuickInputs();
quickInputs.splice(index, 1);
saveQuickInputs(quickInputs);
renderQuickInputs();
});
list.appendChild(item);
});
}
// 切换面板显示/隐藏
function togglePanel() {
const panel = document.getElementById('doubao-quick-input-panel');
if (panel) {
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
}
// ===================== 对话导出相关 =====================
const site = getSiteConfig();
// 取当前页面中可见的消息行(虚拟滚动,仅返回已渲染的)
function getMessageRows() {
for (const sel of site.msgRow) {
const rows = Array.from(document.querySelectorAll(sel));
if (rows.length) return rows;
}
return [];
}
// 找到消息列表的滚动容器(用于向上滚动触发历史懒加载)
function findScrollContainer(el) {
let node = el;
while (node && node !== document.body) {
const style = getComputedStyle(node);
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
}
function scrollToTop(container) {
if (container) container.scrollTop = 0;
else window.scrollTo(0, 0);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 部分平台(如元宝)有「加载更多」按钮,点一下可一次性载入更早的历史
function clickLoadMore() {
if (!site.loadMore) return;
const btns = Array.from(document.querySelectorAll('button'));
btns.forEach(b => {
const t = (b.innerText || '').trim();
if (t && /加载更多|load\s*more|查看更早|更早的消息|上一页/i.test(t)) {
try { b.click(); } catch (e) {}
}
});
}
// 向上滚动(并点击加载更多)加载全部历史消息
async function loadAllMessages(timeout = 7000) {
const firstRows = getMessageRows();
if (!firstRows.length) return;
const container = findScrollContainer(firstRows[0]);
const start = Date.now();
let prevCount = 0;
let clicked = false;
while (Date.now() - start < timeout) {
clickLoadMore();
const cur = getMessageRows().length;
if (cur === prevCount) {
scrollToTop(container);
await sleep(400);
const after = getMessageRows().length;
if (after === cur) {
if (!clicked) { clicked = true; clickLoadMore(); await sleep(600); continue; }
break;
}
prevCount = after;
} else {
prevCount = cur;
scrollToTop(container);
await sleep(500);
}
}
}
// 判断一条消息行的角色:user / assistant / unknown
function getRole(row) {
if (site.isUser(row)) return 'user';
if (site.isAssistant(row)) return 'assistant';
return 'unknown';
}
// 提取单条消息的纯文本/HTML 正文元素
function getMessageContentEl(row) {
return site.contentEl(row);
}
// 轻量 HTML -> Markdown 转换(自包含,无需外部依赖)
function htmlToMarkdown(root) {
if (!root) return '';
let out = '';
function textOf(node) { return node.textContent || ''; }
function walk(node) {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
out += child.textContent;
} else if (child.nodeType === Node.ELEMENT_NODE) {
const tag = child.tagName.toLowerCase();
if (tag === 'pre') {
out += '\n```\n' + textOf(child).replace(/\n$/, '') + '\n```\n';
} else if (tag === 'code') {
out += '`' + textOf(child) + '`';
} else if (tag === 'br') {
out += '\n';
} else if (tag === 'b' || tag === 'strong') {
out += '**' + textOf(child) + '**';
} else if (tag === 'i' || tag === 'em') {
out += '*' + textOf(child) + '*';
} else if (tag === 'a') {
const href = child.getAttribute('href') || '';
out += '[' + textOf(child) + '](' + href + ')';
} else if (tag === 'h1') {
out += '\n# ' + textOf(child).trim() + '\n';
} else if (tag === 'h2') {
out += '\n## ' + textOf(child).trim() + '\n';
} else if (tag === 'h3') {
out += '\n### ' + textOf(child).trim() + '\n';
} else if (tag === 'h4') {
out += '\n#### ' + textOf(child).trim() + '\n';
} else if (tag === 'blockquote') {
out += '\n> ' + textOf(child).trim().replace(/\n/g, '\n> ') + '\n';
} else if (tag === 'ul') {
out += '\n';
child.querySelectorAll(':scope > li').forEach(li => { out += '- ' + textOf(li).trim() + '\n'; });
} else if (tag === 'ol') {
out += '\n';
let i = 1;
child.querySelectorAll(':scope > li').forEach(li => { out += (i++) + '. ' + textOf(li).trim() + '\n'; });
} else if (tag === 'p') {
out += '\n' + textOf(child).trim() + '\n';
} else if (tag === 'img') {
const src = child.getAttribute('src') || '';
out += '';
} else if (tag === 'div' || tag === 'section') {
out += '\n';
walk(child);
out += '\n';
} else {
walk(child);
}
}
}
}
walk(root);
// 清理多余空行
out = out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
return out;
}
// 提取全部消息为结构化数组
function extractMessages() {
const rows = getMessageRows();
const messages = [];
rows.forEach(row => {
const role = getRole(row);
const contentEl = getMessageContentEl(row);
const content = htmlToMarkdown(contentEl);
if (!content) return;
messages.push({ role: role, content: content });
});
return messages;
}
// 生成 Markdown 文本
function buildMarkdown(messages) {
const label = site.label || '对话';
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const timeStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
const userCount = messages.filter(m => m.role === 'user').length;
let md = `# ${label}对话导出\n\n`;
md += `> 导出时间:${timeStr} | 对话轮数:${userCount}\n\n`;
messages.forEach(m => {
const roleLabel = m.role === 'user' ? '用户' : (m.role === 'assistant' ? label : '消息');
md += `## ${roleLabel}\n\n`;
md += m.content + '\n\n';
md += '---\n\n';
});
return md.replace(/\n---\n\n$/, '\n');
}
// 触发浏览器下载
function downloadMarkdown(content) {
const label = site.label || '对话';
const d = new Date();
const pad = n => String(n).padStart(2, '0');
const filename = `${label}对话-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}.md`;
// 加 BOM 头,保证 Windows / Excel 下中文不乱码
const blob = new Blob(['' + content], { type: 'text/markdown; charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
return filename;
}
// 创建导出悬浮球
function createExportBall() {
const ball = document.createElement('div');
ball.id = 'doubao-export-ball';
ball.style.position = 'fixed';
ball.style.right = '20px';
ball.style.top = 'calc(50% + 80px)';
ball.style.transform = 'translateY(-50%)';
ball.style.width = '60px';
ball.style.height = '60px';
ball.style.borderRadius = '50%';
ball.style.backgroundColor = '#52c41a';
ball.style.color = 'white';
ball.style.display = 'flex';
ball.style.alignItems = 'center';
ball.style.justifyContent = 'center';
ball.style.fontSize = '24px';
ball.style.cursor = 'pointer';
ball.style.zIndex = '9999';
ball.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
ball.style.transition = 'all 0.3s ease';
ball.title = '导出当前对话为 Markdown';
ball.innerHTML = '📥';
ball.addEventListener('mouseenter', function() {
ball.style.transform = 'translateY(-50%) scale(1.1)';
});
ball.addEventListener('mouseleave', function() {
ball.style.transform = 'translateY(-50%) scale(1)';
});
return ball;
}
// 轻量提示框
function showToast(msg, duration = 2600) {
let toast = document.getElementById('doubao-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'doubao-toast';
toast.style.position = 'fixed';
toast.style.left = '50%';
toast.style.top = '20%';
toast.style.transform = 'translateX(-50%)';
toast.style.backgroundColor = 'rgba(0,0,0,0.8)';
toast.style.color = 'white';
toast.style.padding = '10px 18px';
toast.style.borderRadius = '8px';
toast.style.fontSize = '14px';
toast.style.zIndex = '10000';
toast.style.pointerEvents = 'none';
toast.style.transition = 'opacity 0.3s ease';
document.body.appendChild(toast);
}
toast.textContent = msg;
toast.style.opacity = '1';
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, duration);
}
// 导出对话主流程
async function exportConversation() {
const exportBall = document.getElementById('doubao-export-ball');
if (exportBall) {
exportBall.style.opacity = '0.5';
exportBall.style.pointerEvents = 'none';
}
try {
showToast('正在加载完整对话...');
await loadAllMessages();
const messages = extractMessages();
if (!messages.length) {
showToast('未找到对话内容,请确认已在对话页面', 3000);
return;
}
const md = buildMarkdown(messages);
const filename = downloadMarkdown(md);
showToast(`导出完成:${messages.length} 条消息 → ${filename}`, 3500);
} catch (e) {
showToast('导出失败:' + (e && e.message ? e.message : e), 3500);
} finally {
if (exportBall) {
exportBall.style.opacity = '1';
exportBall.style.pointerEvents = 'auto';
}
}
}
// ===================== 初始化 =====================
function init() {
// 快捷输入悬浮球(避免重复创建)
if (!document.getElementById('doubao-floating-ball')) {
const ball = createFloatingBall();
document.body.appendChild(ball);
const panel = createQuickInputPanel();
document.body.appendChild(panel);
ball.addEventListener('click', togglePanel);
document.addEventListener('click', function(e) {
const b = document.getElementById('doubao-floating-ball');
const p = document.getElementById('doubao-quick-input-panel');
if (b && p && !b.contains(e.target) && !p.contains(e.target)) {
p.style.display = 'none';
}
});
renderQuickInputs();
}
// 导出悬浮球(避免重复创建)
if (!document.getElementById('doubao-export-ball')) {
const exportBall = createExportBall();
document.body.appendChild(exportBall);
exportBall.addEventListener('click', exportConversation);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();