Greasy Fork

Greasy Fork is available in English.

Lyra Gemini Tracker Exporter (clean + similarity)

跟踪 Gemini 对话的用户编辑与回答重试,使用内容相似度合并 streaming,只导出最终版本 JSON(无冗余字段)

当前为 2025-11-16 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Lyra Gemini Tracker Exporter (clean + similarity)
// @namespace     userscript://lyra-universal-ai-exporter
// @version      3.33
// @description  跟踪 Gemini 对话的用户编辑与回答重试,使用内容相似度合并 streaming,只导出最终版本 JSON(无冗余字段)
// @author       Lyra
// @match        https://gemini.google.com/app/*
// @include      *://gemini.google.com/*
// @run-at       document-end
// @grant        GM_addStyle
// ==/UserScript==

/*
  输出格式示例
  {
    "platform": "gemini",
    "exportedAt": "2025-11-16T09:00:00.000Z",
    "turns": [
      {
        "turnIndex": 0,
        "human": { "versions": ["原始提问", "编辑后提问"] },
        "assistant": {
          "versions": [
            "第一次答案(最终)",
            "retry1 最终答案",
            "retry2 最终答案"
          ],
          "versionGroups": ["0", "1", "2"]
        }
      }
    ]
  }
*/

(function () {
  'use strict';
  if (window.lyraGeminiInitialized) return;
  window.lyraGeminiInitialized = true;

  /* ---------------- util ---------------- */
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  /* ---------------- config ---------------- */
  const CFG = {
    PANEL_ID: 'lyra-gemini-panel',
    TOGGLE_ID: 'lyra-gemini-toggle',
    EXPORT_BTN_ID: 'lyra-gemini-export',
    SCAN_INTERVAL: 1000,
    STREAM_THRESHOLD_MS: 8000 // 作为辅助手段保留,可调大/调小
  };

  /* ---------------- tracker ---------------- */
  const Tracker = {
    turns: {}, // turnIndex -> data
    lastPath: location.pathname,
    reset() {
      this.turns = {};
    }
  };

  /* helper: 提取 turn dom 列表 */
  function queryTurns() {
    return document.querySelectorAll(
      'div.conversation-turn, div.single-turn, div.conversation-container'
    );
  }

  /* helper: 读取用户/助手文本 */
  function extractUserText(turn) {
    const el =
      turn.querySelector('user-query .query-text') ||
      turn.querySelector('.query-text-line');
    return el ? el.innerText.trim() : '';
  }
  function extractAssistantText(turn) {
    const panel =
      turn.querySelector('model-response .markdown-main-panel') ||
      turn.querySelector('.markdown-main-panel') ||
      turn.querySelector('model-response') ||
      turn.querySelector('.response-container');
    return panel ? panel.innerText.trim() : '';
  }

  /* helper: 判断新文本是否同一轮 streaming 的“增量版本” */
  function isStreamingContinuation(oldText, newText) {
    const a = (oldText || '').trim();
    const b = (newText || '').trim();
    if (!a || !b) return false;
    // 找短文本 / 长文本
    let short = a;
    let long = b;
    if (short.length > long.length) {
      short = b;
      long = a;
    }
    const idx = long.indexOf(short);
    if (idx === -1) return false;
    const ratio = short.length / long.length;
    // 短文本占长文本 70% 以上,认为是“同一次回答逐步补充”的过程
    return ratio >= 0.7;
  }

  /* 确保 turn 数据结构 */
  function ensureTurn(idx) {
    let t = Tracker.turns[idx];
    if (!t) {
      t = {
        human: {
          versions: [],
          last: ''
        },
        assistant: {
          versions: [],
          last: '',
          lastUpdate: 0,
          currentIdx: -1
        }
      };
      Tracker.turns[idx] = t;
    }
    return t;
  }

  /* 处理 user 文本,捕捉 edit */
  function handleHuman(idx, text) {
    const t = ensureTurn(idx);
    if (!text) return;
    if (!t.human.last) {
      t.human.last = text;
      t.human.versions.push(text);
    } else if (text !== t.human.last) {
      t.human.last = text;
      t.human.versions.push(text); // 视为 edit
    }
  }

  /* 处理 assistant 文本:用“内容相似度 + 时间”判定 streaming vs retry */
  function handleAssistant(idx, text) {
    if (!text) return;
    const now = Date.now();
    const t = ensureTurn(idx);

    // 第一次
    if (!t.assistant.last) {
      t.assistant.last = text;
      t.assistant.lastUpdate = now;
      t.assistant.currentIdx = 0;
      t.assistant.versions.push(text);
      return;
    }

    // 未变更
    if (text === t.assistant.last) return;

    const delta = now - t.assistant.lastUpdate;
    const byContent = isStreamingContinuation(t.assistant.last, text);
    const byTimePrefix =
      delta < CFG.STREAM_THRESHOLD_MS && text.startsWith(t.assistant.last);

    const streamingUpdate = byContent || byTimePrefix;

    if (streamingUpdate && t.assistant.currentIdx >= 0) {
      // 视为同一条回答的更新,只覆盖当前版本,保留最终形态
      t.assistant.versions[t.assistant.currentIdx] = text;
      t.assistant.last = text;
      t.assistant.lastUpdate = now;
    } else {
      // 视为真正 retry,新建一个版本
      t.assistant.versions.push(text);
      t.assistant.currentIdx = t.assistant.versions.length - 1;
      t.assistant.last = text;
      t.assistant.lastUpdate = now;
    }
  }

  /* 定期扫描 dom 更新 tracker */
  function scan() {
    // conversation 切换检测
    if (location.pathname !== Tracker.lastPath) {
      Tracker.lastPath = location.pathname;
      Tracker.reset();
    }

    const turns = queryTurns();

    turns.forEach((turn, idx) => {
      handleHuman(idx, extractUserText(turn));
      handleAssistant(idx, extractAssistantText(turn));
    });
  }

  /* 构造 assistant versionGroups 数组,如 ["0", "1", "2-3"] */
  function buildGroups(arr) {
    if (arr.length === 0) return [];
    const ranges = [];
    let start = 0;
    for (let i = 1; i < arr.length; i++) {
      // 现在 versions 里只放“最终回答”,每个元素已经是一次 retry 的终结
      ranges.push(start === i - 1 ? `${start}` : `${start}-${i - 1}`);
      start = i;
    }
    ranges.push(start === arr.length - 1 ? `${start}` : `${start}-${arr.length - 1}`);
    return ranges;
  }

  /* 构造导出 JSON */
  function buildExport() {
    scan(); // 最终扫描一次
    const turnIndices = Object.keys(Tracker.turns)
      .map((i) => parseInt(i, 10))
      .sort((a, b) => a - b);

    const turns = turnIndices.map((idx) => {
      const t = Tracker.turns[idx];
      return {
        turnIndex: idx,
        human: {
          versions: [...t.human.versions]
        },
        assistant: {
          versions: [...t.assistant.versions],
          versionGroups: buildGroups(t.assistant.versions)
        }
      };
    });

    return {
      platform: 'gemini',
      exportedAt: new Date().toISOString(),
      turns
    };
  }

  /* 下载 json */
  function download(obj) {
    const json = JSON.stringify(obj, null, 2);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);

    const a = document.createElement('a');
    const date = new Date().toISOString().slice(0, 10);
    a.href = url;
    a.download = `gemini_export_${date}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  /* ---------------- ui ---------------- */
  function injectCSS() {
    GM_addStyle(`
      #${CFG.PANEL_ID}{position:fixed;top:50%;right:0;transform:translateY(-50%) translateX(12px);background:#fff;border:1px solid #dadce0;border-radius:8px;padding:14px 14px 8px;width:140px;z-index:999999;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;box-shadow:0 4px 12px rgba(0,0,0,.15);transition:all .6s cubic-bezier(.4,0,.2,1)}
      #${CFG.PANEL_ID}.collapsed{transform:translateY(-50%) translateX(calc(100% - 34px));opacity:.6;pointer-events:none}
      #${CFG.TOGGLE_ID}{position:absolute;left:0;top:50%;transform:translate(-50%,-50%);width:26px;height:26px;border-radius:50%;background:#fff;border:1px solid #dadce0;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,.2);z-index:1000}
      #${CFG.PANEL_ID}.collapsed #${CFG.TOGGLE_ID}{background:#1a73e8;color:#fff;width:22px;height:22px}
      .lyra-title{font-size:15px;font-weight:700;margin-bottom:10px;text-align:center;color:#202124}
      .lyra-btn{display:flex;align-items:center;justify-content:center;width:100%;padding:8px 10px;border-radius:6px;border:none;background:#1a73e8;color:#fff;font-size:11px;font-weight:500;cursor:pointer}
      .lyra-status{margin-top:6px;font-size:10px;color:#5f6368;text-align:center}
    `);
  }

  function createPanel() {
    if (document.getElementById(CFG.PANEL_ID)) return;
    const panel = document.createElement('div');
    panel.id = CFG.PANEL_ID;

    const toggle = document.createElement('div');
    toggle.id = CFG.TOGGLE_ID;
    toggle.textContent = '<';
    toggle.addEventListener('click', () => {
      const collapsed = panel.classList.toggle('collapsed');
      toggle.textContent = collapsed ? '>' : '<';
    });

    const title = document.createElement('div');
    title.className = 'lyra-title';
    title.textContent = 'Gemini Tracker';

    const btn = document.createElement('button');
    btn.id = CFG.EXPORT_BTN_ID;
    btn.className = 'lyra-btn';
    btn.textContent = '导出 JSON';

    const status = document.createElement('div');
    status.className = 'lyra-status';

    btn.addEventListener('click', async () => {
      btn.disabled = true;
      status.textContent = '生成中…';
      await sleep(100);
      try {
        const data = buildExport();
        download(data);
        status.textContent = '已导出';
      } catch (e) {
        console.error(e);
        status.textContent = '失败: ' + e.message;
      } finally {
        await sleep(800);
        status.textContent = '';
        btn.disabled = false;
      }
    });

    panel.appendChild(toggle);
    panel.appendChild(title);
    panel.appendChild(btn);
    panel.appendChild(status);
    document.body.appendChild(panel);
  }

  /* ---------------- init ---------------- */
  function init() {
    injectCSS();
    createPanel();
    setInterval(scan, CFG.SCAN_INTERVAL);
    console.log('[LyraGemini] tracker running (similarity mode)');
  }

  if (document.readyState === 'loading') {
    window.addEventListener('DOMContentLoaded', () => setTimeout(init, 1200));
  } else {
    setTimeout(init, 1200);
  }
})();