// ==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 // @downloadURL none // ==/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); } })();