Gemini 对话导航

为 Google Gemini 官网添加侧边导航面板;支持点击跳转、收藏对话 (Star)、自动屏蔽底部免责声明。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Gemini 对话导航
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  为 Google Gemini 官网添加侧边导航面板;支持点击跳转、收藏对话 (Star)、自动屏蔽底部免责声明。
// @author       schweigen
// @match        https://gemini.google.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // --- 配置参数 ---
  const CONFIG = {
    maxPreviewLength: 20,     // 导航条文字预览长度
    scrollBehavior: 'auto',   // 'auto'=瞬间(更准), 'smooth'=平滑
    navWidthDefault: 200,     // 默认宽度
    headerHeight: 80,         // 顶部导航栏高度
    readOffset: 120           // 判定阅读线的偏移量(像素)
  };

  // --- 常量与状态 ---
  const STORE_NS = 'gemini-quicknav';
  const WIDTH_KEY = `${STORE_NS}:nav-width`;
  const FAV_KEY_PREFIX = `${STORE_NS}:fav:`; // 收藏(Star)

  let favSet = new Map();  // 收藏数据
  let filterFav = false;   // 是否仅显示收藏
  let cacheIndex = [];     // 当前对话列表缓存
  let currentActiveId = null; // 当前高亮的对话ID
  let isUserScrolling = false;
  let scrollTimer = null;
  let refreshTimer = null;

  // --- CSS 样式 ---
  const css = `
    :root {
      --gn-panel-bg: #ffffff;
      --gn-border: #e5e7eb;
      --gn-text: #374151;
      --gn-text-light: #9ca3af;
      --gn-hover-bg: #f3f4f6;
      --gn-active-bg: #e0e7ff;
      --gn-active-border: #6366f1;
      --gn-user-color: #10b981; /* 绿色 */
      --gn-model-color: #3b82f6; /* 蓝色 */
      --gn-shadow: 0 4px 12px rgba(0,0,0,0.1);
      --gn-radius: 8px;
      --gn-z-index: 9999;
    }
    /* Gemini 暗色模式适配 */
    body.dark-theme, body[data-theme="dark"] {
      --gn-panel-bg: #1e1e1e;
      --gn-border: #333333;
      --gn-text: #e5e7eb;
      --gn-text-light: #6b7280;
      --gn-hover-bg: #2d2d2d;
      --gn-active-bg: #1e1b4b;
      --gn-active-border: #818cf8;
    }

    /* 滚动边距修正,防止标题被顶部遮挡 */
    user-query, model-response {
        scroll-margin-top: 110px !important;
    }

    #gemini-nav-panel {
      position: fixed;
      top: 70px;
      right: 16px;
      width: var(--gn-width, 200px);
      min-width: 120px;
      max-width: 400px;
      height: auto;
      max-height: calc(100vh - 100px);
      background: var(--gn-panel-bg);
      border: 1px solid var(--gn-border);
      border-radius: var(--gn-radius);
      box-shadow: var(--gn-shadow);
      z-index: var(--gn-z-index);
      display: flex;
      flex-direction: column;
      font-family: "Google Sans", Roboto, sans-serif;
      font-size: 13px;
      transition: right 0.2s ease;
    }

    .gn-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 8px 12px;
      border-bottom: 1px solid var(--gn-border);
      cursor: move;
      user-select: none;
    }
    .gn-title { font-weight: 600; color: var(--gn-text-light); font-size: 11px; text-transform: uppercase; }
    .gn-actions { display: flex; gap: 4px; }
    .gn-btn {
      background: transparent; border: 1px solid var(--gn-border); border-radius: 4px;
      color: var(--gn-text); cursor: pointer; width: 24px; height: 24px;
      display: flex; align-items: center; justify-content: center; font-size: 14px;
      transition: all 0.1s;
    }
    .gn-btn:hover { background: var(--gn-hover-bg); color: var(--gn-active-border); }
    .gn-btn.active { background: var(--gn-active-bg); color: var(--gn-active-border); border-color: var(--gn-active-border); }

    .gn-list {
      flex: 1;
      overflow-y: auto;
      padding: 6px;
      display: flex;
      flex-direction: column;
      gap: 4px;
      scrollbar-width: thin;
    }
    .gn-item {
      padding: 6px 8px;
      border-radius: 4px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: space-between;
      border: 1px solid transparent;
      transition: all 0.1s;
      color: var(--gn-text);
      position: relative;
    }
    .gn-item:hover { background: var(--gn-hover-bg); }
    .gn-item.active {
      background: var(--gn-active-bg);
      border-color: var(--gn-active-border);
      box-shadow: inset 3px 0 0 var(--gn-active-border);
    }

    .gn-item.role-user .gn-idx { color: var(--gn-user-color); }
    .gn-item.role-model .gn-idx { color: var(--gn-model-color); }

    .gn-content {
      flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
      margin-right: 6px;
    }
    .gn-idx { font-weight: bold; margin-right: 6px; min-width: 18px; font-size: 11px; }

    .gn-fav-btn {
      opacity: 0.1; cursor: pointer; font-size: 14px; line-height: 1; border: none; background: none; padding: 0;
    }
    .gn-item:hover .gn-fav-btn { opacity: 0.6; }
    .gn-fav-btn:hover { opacity: 1; transform: scale(1.2); }
    .gn-fav-btn.is-fav { opacity: 1; color: #fbbf24; } /* 金色 */

    .gn-footer {
      padding: 8px;
      border-top: 1px solid var(--gn-border);
      display: flex;
      gap: 4px;
    }
    .gn-nav-btn {
      flex: 1; padding: 6px; border: 1px solid var(--gn-border);
      border-radius: 4px; background: var(--gn-panel-bg); cursor: pointer;
      color: var(--gn-text); font-size: 12px;
    }
    .gn-nav-btn:hover { background: var(--gn-hover-bg); }

    .gn-resize-handle {
      position: absolute; left: 0; top: 0; bottom: 0; width: 6px; cursor: ew-resize; z-index: 10;
    }
    .gn-resize-handle:hover { background: rgba(0,0,0,0.05); }

    #gemini-nav-panel.minimized { width: auto; min-width: unset; height: auto; }
    #gemini-nav-panel.minimized .gn-list,
    #gemini-nav-panel.minimized .gn-footer,
    #gemini-nav-panel.minimized .gn-resize-handle { display: none; }
    #gemini-nav-panel.minimized .gn-title { display: none; }

    /* --- 屏蔽免责声明 CSS (集成) --- */
    hallucination-disclaimer,
    hallucination-disclaimer .capabilities-disclaimer,
    [data-test-id="highly-regulated-disclaimer"],
    .footer-disclaimer-container {
      display: none !important;
      visibility: hidden !important;
      height: 0 !important;
      margin: 0 !important;
      padding: 0 !important;
      opacity: 0 !important;
      pointer-events: none !important;
    }
  `;

  // --- DOM 工具函数 ---
  function createEl(tag, className, text, props = {}) {
    const el = document.createElement(tag);
    if (className) el.className = className;
    if (text) el.textContent = text;
    for (const [k, v] of Object.entries(props)) {
      if (k === 'dataset') {
        for (const [dk, dv] of Object.entries(v)) el.dataset[dk] = dv;
      } else if (k === 'style') {
        Object.assign(el.style, v);
      } else if (k.startsWith('on') && typeof v === 'function') {
        el.addEventListener(k.substring(2).toLowerCase(), v);
      } else {
        el[k] = v;
      }
    }
    return el;
  }

  // --- 核心逻辑 ---

  function getStorageKey() {
    const path = window.location.pathname;
    return path.length > 5 ? path : 'global';
  }

  function scanTurns() {
    const nodes = document.querySelectorAll('user-query, model-response');
    const turns = [];
    let uCount = 0, mCount = 0;

    nodes.forEach((node, index) => {
      if (node.offsetHeight === 0) return;

      // 稳定 ID 生成 (去掉了 Date.now 以保证刷新后 ID 不变,利于收藏)
      let id = node.getAttribute('data-nav-id');
      if (!id) {
        id = `gn-turn-${index}`;
        node.setAttribute('data-nav-id', id);
      }

      const isUser = node.tagName.toLowerCase() === 'user-query';
      let text = '';

      if (isUser) {
        uCount++;
        const textEl = node.querySelector('.query-text') || node.querySelector('div[class*="query-content"]');
        text = textEl ? textEl.innerText : 'User Input';
      } else {
        mCount++;
        const textEl = node.querySelector('.markdown') || node.querySelector('.model-response-text');
        text = textEl ? textEl.innerText : 'Gemini Response';
      }

      text = (text || '').replace(/\s+/g, ' ').trim();
      const preview = text.length > CONFIG.maxPreviewLength ? text.substring(0, CONFIG.maxPreviewLength) + '...' : text;

      turns.push({
        id: id,
        index: index,
        role: isUser ? 'role-user' : 'role-model',
        label: isUser ? `U${uCount}` : `G${mCount}`,
        preview: preview,
        el: node
      });
    });

    return turns;
  }

  function getDisplayList() {
    const baseTurns = scanTurns();
    const sKey = getStorageKey();

    if (!favSet.has(sKey)) favSet.set(sKey, new Set(GM_getValue(FAV_KEY_PREFIX + sKey, [])));

    const currentFavs = favSet.get(sKey);

    let displayList = baseTurns.map(item => {
      return {
        ...item,
        isFav: currentFavs.has(item.id)
      };
    });

    if (filterFav) {
      displayList = displayList.filter(item => item.isFav);
    }

    return displayList;
  }

  function renderPanel() {
    let panel = document.getElementById('gemini-nav-panel');
    let listEl;

    if (!panel) {
      const style = document.createElement('style');
      style.textContent = css;
      document.head.appendChild(style);

      panel = createEl('div', '', '', { id: 'gemini-nav-panel' });
      const savedWidth = GM_getValue(WIDTH_KEY, CONFIG.navWidthDefault);
      panel.style.setProperty('--gn-width', `${savedWidth}px`);

      const resizeHandle = createEl('div', 'gn-resize-handle');
      panel.appendChild(resizeHandle);

      const header = createEl('div', 'gn-header');
      const title = createEl('div', 'gn-title', 'Gemini Nav');

      const actions = createEl('div', 'gn-actions');
      const btnFav = createEl('button', 'gn-btn', '★', { id: 'gn-toggle-fav', title: '显示收藏' });
      const btnRefresh = createEl('button', 'gn-btn', '⟳', { id: 'gn-refresh', title: '刷新' });
      const btnMin = createEl('button', 'gn-btn', '_', { id: 'gn-minimize', title: '收起' });

      actions.appendChild(btnFav);
      actions.appendChild(btnRefresh);
      actions.appendChild(btnMin);

      header.appendChild(title);
      header.appendChild(actions);
      panel.appendChild(header);

      listEl = createEl('div', 'gn-list');
      panel.appendChild(listEl);

      const footer = createEl('div', 'gn-footer');
      const btnPrev = createEl('button', 'gn-nav-btn', '▲', { id: 'gn-go-prev', title: '上一条' });
      const btnNext = createEl('button', 'gn-nav-btn', '▼', { id: 'gn-go-next', title: '下一条' });

      footer.appendChild(btnPrev);
      footer.appendChild(btnNext);
      panel.appendChild(footer);

      document.body.appendChild(panel);

      bindEvents(panel);
    } else {
      listEl = panel.querySelector('.gn-list');
    }

    while (listEl.firstChild) {
      listEl.removeChild(listEl.firstChild);
    }

    const items = getDisplayList();
    cacheIndex = items;

    if (items.length === 0) {
      const empty = createEl('div', '', '暂无内容', { style: { padding: '10px', color: '#999', textAlign: 'center' } });
      listEl.appendChild(empty);
    } else {
      items.forEach(item => {
        let classes = `gn-item ${item.role} ${item.id === currentActiveId ? 'active' : ''}`;

        const row = createEl('div', classes, '', { dataset: { id: item.id } });

        const idx = createEl('div', 'gn-idx', item.label);
        const content = createEl('div', 'gn-content', item.preview, { title: item.preview });
        const favClass = `gn-fav-btn ${item.isFav ? 'is-fav' : ''}`;
        const favBtn = createEl('button', favClass, '★', { title: '收藏' });

        row.addEventListener('click', () => scrollToItem(item.id));
        favBtn.addEventListener('click', (e) => {
          e.stopPropagation();
          toggleFav(item.id);
        });

        row.appendChild(idx);
        row.appendChild(content);
        row.appendChild(favBtn);
        listEl.appendChild(row);
      });
    }

    const favBtnToggle = panel.querySelector('#gn-toggle-fav');
    if(filterFav) favBtnToggle.classList.add('active');
    else favBtnToggle.classList.remove('active');
  }

  function toggleFav(id) {
    const sKey = getStorageKey();
    let currentFavs = favSet.get(sKey);
    if (!currentFavs) { currentFavs = new Set(); favSet.set(sKey, currentFavs); }

    if (currentFavs.has(id)) currentFavs.delete(id);
    else currentFavs.add(id);

    GM_setValue(FAV_KEY_PREFIX + sKey, Array.from(currentFavs));
    renderPanel();
  }

  function bindEvents(panel) {
    panel.querySelector('#gn-minimize').addEventListener('click', () => {
      panel.classList.toggle('minimized');
      const btn = panel.querySelector('#gn-minimize');
      btn.textContent = panel.classList.contains('minimized') ? '+' : '_';
    });

    panel.querySelector('#gn-refresh').addEventListener('click', () => {
        detectActiveItem(); // 刷新时强制更新一次高亮状态
        renderPanel();
    });

    panel.querySelector('#gn-toggle-fav').addEventListener('click', () => {
      filterFav = !filterFav;
      renderPanel();
    });

    panel.querySelector('#gn-go-prev').addEventListener('click', () => navigate(-1));
    panel.querySelector('#gn-go-next').addEventListener('click', () => navigate(1));

    const handle = panel.querySelector('.gn-resize-handle');
    let isResizing = false;
    handle.addEventListener('mousedown', (e) => {
      isResizing = true;
      e.preventDefault();
      document.body.style.cursor = 'ew-resize';
    });
    document.addEventListener('mousemove', (e) => {
      if (!isResizing) return;
      const newWidth = window.innerWidth - e.clientX - 20;
      if (newWidth > 100 && newWidth < 500) {
        panel.style.setProperty('--gn-width', `${newWidth}px`);
      }
    });
    document.addEventListener('mouseup', () => {
      if (isResizing) {
        isResizing = false;
        document.body.style.cursor = 'default';
        const w = parseFloat(getComputedStyle(panel).getPropertyValue('--gn-width'));
        GM_setValue(WIDTH_KEY, w);
      }
    });

    const scroller = getScroller();
    if (scroller) scroller.addEventListener('scroll', onScroll, { capture: true, passive: true });
    window.addEventListener('scroll', onScroll, { capture: true, passive: true });
  }

  function getScroller() {
    return document.querySelector('infinite-scroller') ||
           document.querySelector('.chat-history-scroll-container') ||
           document.scrollingElement ||
           document.body;
  }

  function scrollToItem(id) {
    const target = cacheIndex.find(i => i.id === id);
    if (target && target.el) {
      // 锁定滚动监听,防止自动判定覆盖点击操作
      isUserScrolling = true;
      if (scrollTimer) clearTimeout(scrollTimer);

      target.el.scrollIntoView({ behavior: CONFIG.scrollBehavior, block: 'start' });
      currentActiveId = id;
      highlightActive();

      // 延迟释放锁定
      setTimeout(() => { isUserScrolling = false; detectActiveItem(); }, 500);
    }
  }

  // --- 稳健跳转:基于当前 Active ID ---
  function navigate(dir) {
    if (cacheIndex.length === 0) return;

    let currentIndex = -1;

    // 1. 优先基于当前高亮ID查找索引
    if (currentActiveId) {
        currentIndex = cacheIndex.findIndex(item => item.id === currentActiveId);
    }

    // 2. 如果没高亮,则使用视口位置回退查找
    if (currentIndex === -1) {
        const readLine = CONFIG.headerHeight + CONFIG.readOffset;
        for (let i = 0; i < cacheIndex.length; i++) {
            const item = cacheIndex[i];
            if (item.el) {
                const rect = item.el.getBoundingClientRect();
                if (rect.bottom > readLine) {
                    currentIndex = i;
                    break;
                }
            }
        }
    }

    // 3. 边界情况处理
    if (currentIndex === -1) currentIndex = dir > 0 ? -1 : cacheIndex.length;

    let nextIdx = currentIndex + dir;
    if (nextIdx < 0) nextIdx = 0;
    if (nextIdx >= cacheIndex.length) nextIdx = cacheIndex.length - 1;

    const targetItem = cacheIndex[nextIdx];
    if (targetItem) scrollToItem(targetItem.id);
  }

  function highlightActive() {
    const panel = document.getElementById('gemini-nav-panel');
    if (!panel) return;

    const listItems = panel.querySelectorAll('.gn-item');
    listItems.forEach(item => {
      if (item.dataset.id === currentActiveId) {
        item.classList.add('active');
        item.scrollIntoView({ block: 'nearest', behavior: 'auto' });
      } else {
        item.classList.remove('active');
      }
    });
  }

  function onScroll() {
    if (isUserScrolling) return;
    if (scrollTimer) clearTimeout(scrollTimer);
    scrollTimer = setTimeout(() => {
      detectActiveItem();
    }, 150);
  }

  // --- 稳健判定:基于阅读线 ---
  function detectActiveItem() {
    const readLine = CONFIG.headerHeight + CONFIG.readOffset;
    let activeId = null;

    for (const item of cacheIndex) {
      if (item.el) {
        const rect = item.el.getBoundingClientRect();
        // 只要元素占据了阅读线下方,且顶部在屏幕内或上方,即视为当前阅读项
        if (rect.bottom > readLine && rect.top < window.innerHeight) {
            activeId = item.id;
            break;
        }
      }
    }

    if (activeId && activeId !== currentActiveId) {
      currentActiveId = activeId;
      highlightActive();
    }
  }

  function initObserver() {
    let lastUrl = location.href;

    const bodyObserver = new MutationObserver(() => {
      if (refreshTimer) clearTimeout(refreshTimer);
      refreshTimer = setTimeout(() => {
        // 简单优化:只有DOM数量变了才重绘
        const currentCount = document.querySelectorAll('user-query, model-response').length;
        if (currentCount !== cacheIndex.length) {
            renderPanel();
        }
      }, 500);
    });

    bodyObserver.observe(document.body, { childList: true, subtree: true });

    setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        currentActiveId = null;
        setTimeout(renderPanel, 1000);
      }
    }, 1000);
  }

  setTimeout(() => {
    renderPanel();
    initObserver();
    setTimeout(detectActiveItem, 500);
    console.log('Gemini QuickNav Loaded (v1.0.0).');
  }, 1500);

})();