Greasy Fork

Greasy Fork is available in English.

Lyra Gemini Tracker Exporter (clean + similarity streaming)

跟踪 Gemini 对话的用户编辑与回答重试,使用文本相似度区分流式更新与 retry,导出精简 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 streaming)
// @namespace    userscript://lyra-gemini-tracker
// @version      4
// @description  跟踪 Gemini 对话的用户编辑与回答重试,使用文本相似度区分流式更新与 retry,导出精简 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: 60000,
    STREAM_RATIO: 0.7 // 短文本长度 / 长文本长度 >= 0.7 视为同一次生成
  };

  /* ---------------- 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() : '';
  }

  /* 文本相似度:判断 newText 是否是 oldText 的“超集版本” */
  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;
    return ratio >= CFG.STREAM_RATIO;
  }

  /* 确保 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 与 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 similar = isStreamingContinuation(t.assistant.last, text);
    const timeOk = delta < CFG.STREAM_THRESHOLD_MS;

    const streamingUpdate = similar && timeOk;

    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++) {
      // assistant.versions[i] 的诞生标志 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 with similarity-based streaming detection');
  }

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