// ==UserScript== // @name 豆包用户消息气泡导航v10 // @namespace https://github.com/yourname/doubao-nav-v9 // @version 2.0.1.0 // @description 基于DOM结构,在豆包虚拟滚动技术下,为豆包网页版增加一个目录功能,更适合从0到n的新建对话。由于用的是虚拟功能,不可避免存在部分导航无法定位、刷新页面后需要可能需要重新浏览整个对话才能获得完整目录。 // @author 你 // @match https://www.doubao.com/* // @grant none // @tag 导航 // @tag 目录 // @tag 工具 // @tag 豆包 // ==/UserScript== (function () { 'use strict'; // ============================================================ // 1. 样式 — 完全对齐稳定版 // ============================================================ const CSS = ` .usr-nav-native { position: fixed; right: 10px; top: 50%; transform: translateY(-50%); z-index: 10; pointer-events: none; } .usr-nav-indicator { width: 16px; transition: width 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background 0.3s ease, box-shadow 0.3s ease, backdrop-filter 0.3s ease; overflow: hidden; background: transparent; backdrop-filter: none; box-shadow: none; border-radius: 10px 0 0 10px; display: flex; flex-direction: column; align-items: stretch; max-height: 288px; cursor: pointer; pointer-events: auto; scroll-behavior: smooth; border-left: 0.5px solid rgba(0,0,0,0.04); } .usr-nav-indicator:hover { width: 260px; overflow-y: auto; background: linear-gradient(135deg, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0.35) 100%); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); box-shadow: -4px 0 24px rgba(0,0,0,0.06), 0 0 0 0.5px rgba(255,255,255,0.6) inset; } .usr-nav-item { height: 40px; width: 100%; position: relative; display: flex; align-items: center; flex-shrink: 0; padding-left: 6px; box-sizing: border-box; border-radius: 6px; transition: background 0.15s ease, border-radius 0.15s ease; } .usr-nav-dash-marker { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); width: 10px; height: 3px; border-radius: 3px; background: var(--s-color-text-tertiary, rgba(100, 110, 125, 0.55)); transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); z-index: 2; } .usr-nav-item.active .usr-nav-dash-marker { width: 14px; height: 4px; background: linear-gradient(90deg, #165DFF, #4080FF); box-shadow: 0 0 6px rgba(22,93,255,0.35); border-radius: 3px; } .usr-nav-item .usr-nav-text { display: none; } .usr-nav-indicator:hover .usr-nav-text { display: block; } .usr-nav-text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; font-weight: 400; line-height: 1.5; color: color-mix(in srgb, var(--s-color-text-primary, #1d1d1f) 45%, transparent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 16px; margin-left: 6px; transition: color 0.15s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); transform: translateX(-4px); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .usr-nav-indicator:hover .usr-nav-text { transform: translateX(0); } .usr-nav-indicator:hover .usr-nav-item.active .usr-nav-text { color: #165DFF; font-weight: 500; } .usr-nav-indicator:hover .usr-nav-item:hover:not(.active) { border-radius: 8px; } .usr-nav-indicator:hover .usr-nav-item:hover:not(.active) .usr-nav-text { color: var(--s-color-text-primary, #1d1d1f); } .usr-nav-indicator:hover .usr-nav-item:hover .usr-nav-dash-marker { background: rgba(22,93,255,0.4); width: 12px; height: 3.5px; } .usr-nav-indicator::-webkit-scrollbar { width: 3px; } .usr-nav-indicator::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 3px; } .usr-nav-indicator::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.2); } .usr-nav-indicator::-webkit-scrollbar-track { background: transparent; } `; document.head.appendChild(Object.assign(document.createElement('style'), { textContent: CSS })); // ============================================================ // 2. 选择器 — 完全对齐稳定版 // ============================================================ const USER_BUBBLE_SEL = 'div[class*="send-msg-bubble"]'; let indicator = null; function getMessageId(bubble) { const msgEl = bubble.closest('[data-message-id]'); return msgEl ? msgEl.dataset.messageId : null; } function getCurrentBubbles() { const bubbles = document.querySelectorAll(USER_BUBBLE_SEL); const result = []; bubbles.forEach(b => { const id = getMessageId(b); if (id) result.push({ id, bubble: b }); }); return result; } // ============================================================ // 2b. IntersectionObserver — 高亮跟踪主要机制(对齐稳定版) // ============================================================ const io = new IntersectionObserver((entries) => { let bestId = null, bestRatio = 0; entries.forEach(e => { if (e.isIntersecting && e.intersectionRatio > bestRatio) { bestRatio = e.intersectionRatio; const id = getMessageId(e.target); if (id) bestId = id; } }); if (bestId && bestId !== activeId) { setActive(bestId); } }, { threshold: [0, 0.2, 0.5, 1.0] }); function observeBubbles() { io.disconnect(); document.querySelectorAll(USER_BUBBLE_SEL).forEach(b => { if (getMessageId(b)) io.observe(b); }); } // ============================================================ // 3. 多会话存储池 — 按 conversation_id 分池,切换对话自动换池 // ============================================================ function getConversationId() { const match = window.location.pathname.match(/^\/chat\/(\d+)/); return match ? match[1] : null; } const pools = new Map(); // convId → { registry: Map, idOrder: [], activeId: null } let currentConvId = null; let activeId = null; // 当前池的激活消息 ID let switchCooldown = 0; // 对话切换后 1.5s 冷却期,防止轮询读到旧 DOM function switchToConversation(convId) { if (convId === currentConvId) return; currentConvId = convId; pools.set(convId, { registry: new Map(), idOrder: [], activeId: null }); activeId = null; if (indicator) indicator.innerHTML = ''; switchCooldown = Date.now() + 1500; // 冷却 1.5s,等 React 更新 DOM } function pool() { const convId = getConversationId() || '__default__'; if (convId !== currentConvId) { switchToConversation(convId); } return pools.get(currentConvId); } // 将 visible 中新发现的 ID 按 DOM-relative 位置插入 idOrder // DOM 顺序即时间序(聊天页面保证),比 BigInt 排序更可靠 function mergeNewIds(p, visible) { let added = false; visible.forEach(({ id }, i) => { if (p.idOrder.includes(id)) return; // 找 DOM 中左侧最近的已知邻居 let pred = null; for (let j = i - 1; j >= 0; j--) { if (p.idOrder.includes(visible[j].id)) { pred = visible[j].id; break; } } // 找 DOM 中右侧最近的已知邻居 let succ = null; for (let j = i + 1; j < visible.length; j++) { if (p.idOrder.includes(visible[j].id)) { succ = visible[j].id; break; } } if (pred) { p.idOrder.splice(p.idOrder.indexOf(pred) + 1, 0, id); } else if (succ) { p.idOrder.splice(p.idOrder.indexOf(succ), 0, id); } else { p.idOrder.push(id); } added = true; }); return added; } function ingest() { if (Date.now() < switchCooldown) return; // 对话切换冷却期,等 React 更新 DOM const p = pool(); if (!p) return; const visible = getCurrentBubbles(); // 先注册所有可见 ID(不插入 idOrder,由 mergeNewIds 统一处理) visible.forEach(({ id, bubble }) => { const label = bubble.textContent.trim().slice(0, 22); if (p.registry.has(id)) { p.registry.get(id).label = label; } else { p.registry.set(id, { label, row: null }); } }); // 按 DOM 相对位置插入新 ID if (mergeNewIds(p, visible)) { rebuildPanel(); } else { updateLabelsInPlace(); } updateActiveByScroll(); observeBubbles(); } // ============================================================ // 4. 面板构建 — 增量复用 DOM,仅在顺序变化时重建 // ============================================================ function createNavItem(id, label, index) { const item = document.createElement('div'); item.className = 'usr-nav-item'; item.dataset.messageId = id; const marker = document.createElement('span'); marker.className = 'usr-nav-dash-marker'; const text = document.createElement('span'); text.className = 'usr-nav-text'; text.textContent = `${index + 1}. ${label}`; item.appendChild(marker); item.appendChild(text); return item; } function rebuildPanel() { const p = pool(); if (!p) return; const oldMap = new Map(); indicator.querySelectorAll('.usr-nav-item').forEach(el => { oldMap.set(el.dataset.messageId, el); }); const frag = document.createDocumentFragment(); p.idOrder.forEach((id, i) => { const entry = p.registry.get(id); if (!entry) return; let item = oldMap.get(id); if (item) { oldMap.delete(id); } else { item = createNavItem(id, entry.label, i); } entry.row = item; const textEl = item.querySelector('.usr-nav-text'); if (textEl) textEl.textContent = `${i + 1}. ${entry.label}`; frag.appendChild(item); }); oldMap.forEach(el => el.remove()); indicator.innerHTML = ''; indicator.appendChild(frag); // 重建后重新挂载 active 类(DOM 已全新,旧类丢失) if (activeId) { const cur = indicator.querySelector(`.usr-nav-item[data-message-id="${activeId}"]`); if (cur) cur.classList.add('active'); } } function updateLabelsInPlace() { const p = pool(); if (!p) return; const items = indicator.querySelectorAll('.usr-nav-item'); items.forEach((item) => { const id = item.dataset.messageId; const entry = p.registry.get(id); if (!entry) return; entry.row = item; const idx = p.idOrder.indexOf(id); if (idx < 0) return; const textEl = item.querySelector('.usr-nav-text'); if (textEl) textEl.textContent = `${idx + 1}. ${entry.label}`; }); } // ============================================================ // 5. 激活高亮(lastActiveId 模式) // ============================================================ function updateActiveByScroll() { const visible = getCurrentBubbles(); if (visible.length === 0) return; const viewCenter = window.innerHeight / 2; let bestId = null, bestDist = Infinity; visible.forEach(({ id, bubble }) => { const rect = bubble.getBoundingClientRect(); if (rect.height === 0) return; const dist = Math.abs(rect.top + rect.height / 2 - viewCenter); if (dist < bestDist) { bestDist = dist; bestId = id; } }); if (bestId && bestId !== activeId) { setActive(bestId); } } function setActive(id) { if (activeId) { const prev = indicator.querySelector(`.usr-nav-item[data-message-id="${activeId}"]`); if (prev) prev.classList.remove('active'); } if (id) { const next = indicator.querySelector(`.usr-nav-item[data-message-id="${id}"]`); if (next) { next.classList.add('active'); setTimeout(() => { next.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 10); activeId = id; return; } } activeId = null; } // ============================================================ // 6. 跳转 — 兼容虚拟滚动 // ============================================================ function scrollToMessage(id) { const visible = getCurrentBubbles(); const match = visible.find(b => b.id === id); if (match) { match.bubble.scrollIntoView({ behavior: 'smooth', block: 'center' }); match.bubble.style.transition = 'box-shadow 0.2s'; match.bubble.style.boxShadow = '0 0 0 3px rgba(22,93,255,0.4)'; setTimeout(() => { match.bubble.style.boxShadow = ''; }, 1500); return; } // 虚拟滚动:目标不在当前 DOM,估算方向 const p = pool(); if (!p) return; const targetIdx = p.idOrder.indexOf(id); if (targetIdx < 0) return; const visibleIds = visible.map(b => b.id); if (visibleIds.length === 0) return; const visFirst = p.idOrder.indexOf(visibleIds[0]); const visLast = p.idOrder.indexOf(visibleIds[visibleIds.length - 1]); if (targetIdx < visFirst) { window.scrollBy(0, -(window.innerHeight * 0.7)); } else if (targetIdx > visLast) { window.scrollBy(0, window.innerHeight * 0.7); } else { return; } setTimeout(() => scrollToMessage(id), 400); } // ============================================================ // 7. 事件系统 // ============================================================ let scrollTimer; window.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(updateActiveByScroll, 80); }, { passive: true }); let pending = false; function scheduleSync() { if (pending) return; pending = true; requestAnimationFrame(() => { ingest(); pending = false; }); } // ============================================================ // 8. 初始化 — 对齐稳定版的时序 // ============================================================ function watchUrlChanges() { const origPush = history.pushState; const origReplace = history.replaceState; function onUrlChange() { const newConvId = getConversationId() || '__default__'; if (newConvId !== currentConvId) { // 立即切池+清面板,但不主动 sync // DOM 更新后 MutationObserver 会自然触发 ingest,避免读到旧 DOM switchToConversation(newConvId); } } history.pushState = function (...args) { origPush.apply(this, args); onUrlChange(); }; history.replaceState = function (...args) { origReplace.apply(this, args); onUrlChange(); }; window.addEventListener('popstate', onUrlChange); } async function init() { for (let i = 0; i < 100; i++) { const hasChat = document.querySelector('.scrollable-Se7zNt') || document.querySelector('[data-message-id]'); if (hasChat) break; await new Promise(r => setTimeout(r, 300)); } const outer = document.createElement('div'); outer.className = 'usr-nav-native'; indicator = document.createElement('div'); indicator.className = 'usr-nav-indicator'; outer.appendChild(indicator); document.body.appendChild(outer); // 事件委托:点击导航项 → 跳转到对应消息 indicator.addEventListener('click', (e) => { const item = e.target.closest('.usr-nav-item'); if (item && item.dataset.messageId) { scrollToMessage(item.dataset.messageId); } }); // 监听 SPA 路由切换(pushState / replaceState / popstate) watchUrlChanges(); ingest(); const chat = document.querySelector('.scrollable-Se7zNt') || document.querySelector('[data-message-id]')?.closest('div[class*="scroll"]'); if (chat) { const mo = new MutationObserver(() => scheduleSync()); mo.observe(chat, { childList: true, subtree: true }); // 聊天容器滚动监听(虚拟滚动在容器内,window scroll 可能不触发) chat.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(updateActiveByScroll, 80); }, { passive: true }); } // 兜底:容器重绑定 let currentChat = chat; const bodyMo = new MutationObserver(() => { const newChat = document.querySelector('.scrollable-Se7zNt'); if (newChat && newChat !== currentChat) { currentChat = newChat; const mo = new MutationObserver(() => scheduleSync()); mo.observe(newChat, { childList: true, subtree: true }); newChat.addEventListener('scroll', () => { clearTimeout(scrollTimer); scrollTimer = setTimeout(updateActiveByScroll, 80); }, { passive: true }); scheduleSync(); } }); bodyMo.observe(document.body, { childList: true, subtree: true }); // 终极兜底:每 4 秒轮询一次,MutationObserver 失效时仍能同步 // scheduleSync 自带 rAF 防抖,ingest 无新消息时仅轻量更新,开销可忽略 setInterval(() => scheduleSync(), 4000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(init, 100)); } else { setTimeout(init, 100); } })();