Greasy Fork is available in English.
跟踪 Gemini 对话的用户编辑与回答重试,使用内容相似度合并 streaming,只导出最终版本 JSON(无冗余字段)
当前为
// ==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);
}
})();