Export chat conversations to Markdown files with model names, thinking/reasoning blocks, and complete message history.
// ==UserScript==
// @name LMArena | Conversation/Chat Markdown-Export/Download (API-based)
// @namespace https://greasyfork.org/en/users/1462137-piknockyou
// @version 7.1
// @author Piknockyou (vibe-coded)
// @license AGPL-3.0
// @description Export chat conversations to Markdown files with model names, thinking/reasoning blocks, and complete message history.
// @match *://lmarena.ai/*
// @match *://chat.lmsys.org/*
// @icon https://lmarena.ai/favicon.ico
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const CONFIG = {
INCLUDE_THINKING: true,
COLLAPSIBLE_THINKING: true,
INCLUDE_MODEL_NAMES: true,
FILENAME_MAX_LEN: 100,
BUTTON_SIZE: 50,
BUTTON_COLOR_READY: '#22c55e',
BUTTON_COLOR_LOADING: '#f59e0b',
BUTTON_COLOR_ERROR: '#ef4444',
Z_INDEX: 2147483647,
STORAGE_KEY: 'lmarena_export_pos_v7'
};
// ══════════════════════════════════════════════════════════════════════════
// STATE
// ══════════════════════════════════════════════════════════════════════════
const State = {
status: 'idle',
currentChatId: null,
error: null
};
// ══════════════════════════════════════════════════════════════════════════
// API - Always fetch fresh data on export
// ══════════════════════════════════════════════════════════════════════════
const API = {
extractChatId(url) {
const match = url.match(/\/(?:c|chat)\/([a-zA-Z0-9-]+)/);
if (match && match[1] !== 'new') {
return match[1];
}
return null;
},
async fetchChat(chatId) {
const response = await fetch(`/api/evaluation/${chatId}`, {
credentials: 'include',
headers: { 'Accept': 'application/json' }
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
if (!data.messages || !Array.isArray(data.messages) || data.messages.length === 0) {
throw new Error('No messages found');
}
return data;
}
};
// ══════════════════════════════════════════════════════════════════════════
// MODEL NAME EXTRACTION FROM DOM
// ══════════════════════════════════════════════════════════════════════════
const ModelExtractor = {
/**
* Extract model names from DOM in order.
* These correspond 1:1 with assistant messages from the API.
*/
getModelNamesFromDOM() {
// Primary selector: sticky headers in message list
const selectors = [
'ol.mt-8 div.sticky span.truncate',
'ol[class*="mt-8"] div[class*="sticky"] span[class*="truncate"]',
'[class*="bg-surface-primary"] [class*="sticky"] span.truncate'
];
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
return [...elements]
.map(el => el.textContent?.trim())
.filter(Boolean);
}
}
return [];
},
/**
* Build an array of model names indexed by assistant message position.
* Returns an array where index i = model name for assistant message i.
*/
buildModelNamesArray() {
return this.getModelNamesFromDOM();
}
};
// ══════════════════════════════════════════════════════════════════════════
// MARKDOWN GENERATOR
// ══════════════════════════════════════════════════════════════════════════
function generateMarkdown(chatData) {
const sanitize = (s) => {
if (!s) return 'LMArena_Export';
return s.replace(/[\r\n]+/g, ' ')
.replace(/[#`*\[\]:\/\\?*|"<>;]/g, '')
.trim()
.substring(0, CONFIG.FILENAME_MAX_LEN) || 'LMArena_Export';
};
const firstUserMsg = chatData.messages.find(m => m.role === 'user');
const titleSource = chatData.title || firstUserMsg?.content?.substring(0, 60) || 'Untitled';
const title = sanitize(titleSource);
const date = chatData.createdAt
? new Date(chatData.createdAt).toLocaleString()
: new Date().toLocaleString();
// Get model names from DOM (order corresponds to assistant messages)
const modelNames = CONFIG.INCLUDE_MODEL_NAMES
? ModelExtractor.buildModelNamesArray()
: [];
let assistantIndex = 0;
let md = `# ${title}\n\n`;
md += `> **Date:** ${date} \n`;
md += `> **Chat ID:** ${chatData.id} \n`;
md += `> **Source:** [LMArena](${location.href}) \n\n---\n\n`;
chatData.messages.forEach((msg, i) => {
const role = (msg.role || 'unknown').toUpperCase();
const content = msg.content || '';
const reasoning = msg.reasoning || '';
// Get model name for assistant messages
let modelInfo = '';
if (msg.role === 'assistant' && CONFIG.INCLUDE_MODEL_NAMES) {
const modelName = modelNames[assistantIndex];
if (modelName) {
modelInfo = ` (${modelName})`;
}
assistantIndex++;
}
md += `### [${i + 1}] ${role}${modelInfo}\n\n`;
if (CONFIG.INCLUDE_THINKING && reasoning) {
const quoted = reasoning.replace(/\n/g, '\n> ');
if (CONFIG.COLLAPSIBLE_THINKING) {
md += `<details>\n<summary><strong>Thinking</strong></summary>\n\n> ${quoted}\n\n</details>\n\n`;
} else {
md += `> **Thinking:**\n> ${quoted}\n\n`;
}
}
md += `${content}\n\n---\n\n`;
});
return {
content: md,
filename: `${title.replace(/\s+/g, '_')}.md`
};
}
// ══════════════════════════════════════════════════════════════════════════
// URL WATCHER
// ══════════════════════════════════════════════════════════════════════════
const Router = {
lastUrl: null,
isChatPage() {
const chatId = API.extractChatId(location.href);
return chatId !== null;
},
getCurrentChatId() {
return API.extractChatId(location.href);
},
init() {
this.lastUrl = location.href;
const checkUrl = () => {
if (location.href !== this.lastUrl) {
this.lastUrl = location.href;
this.onChange();
}
};
new MutationObserver(checkUrl).observe(document.body, {
childList: true,
subtree: true
});
window.addEventListener('popstate', checkUrl);
setInterval(checkUrl, 300);
},
onChange() {
const chatId = this.getCurrentChatId();
if (chatId) {
State.currentChatId = chatId;
State.status = 'ready';
State.error = null;
UI.show();
} else {
State.currentChatId = null;
State.status = 'idle';
UI.hide();
}
UI.update();
}
};
// ══════════════════════════════════════════════════════════════════════════
// UI - Ratio-based positioning, right-click drag
// ══════════════════════════════════════════════════════════════════════════
const UI = {
btn: null,
isDragging: false,
isExporting: false,
init() {
if (document.getElementById('lma-export-btn')) return;
const btn = document.createElement('div');
btn.id = 'lma-export-btn';
btn.innerHTML = `<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
Object.assign(btn.style, {
position: 'fixed',
width: `${CONFIG.BUTTON_SIZE}px`,
height: `${CONFIG.BUTTON_SIZE}px`,
borderRadius: '50%',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
display: 'none',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: CONFIG.Z_INDEX,
userSelect: 'none',
transition: 'background-color 0.2s, transform 0.1s, opacity 0.2s',
color: '#fff'
});
this.loadPosition(btn);
btn.addEventListener('click', (e) => {
if (e.button !== 0 || this.isDragging) return;
this.export();
});
btn.addEventListener('contextmenu', (e) => e.preventDefault());
btn.addEventListener('mousedown', (e) => {
if (e.button === 2) {
e.preventDefault();
this.startDrag(e);
}
});
document.body.appendChild(btn);
this.btn = btn;
window.addEventListener('resize', () => this.applyRatioPosition());
if (Router.isChatPage()) {
State.currentChatId = Router.getCurrentChatId();
State.status = 'ready';
this.show();
}
this.update();
},
loadPosition(btn) {
const saved = localStorage.getItem(CONFIG.STORAGE_KEY);
if (saved) {
try {
const { ratioX, ratioY } = JSON.parse(saved);
this.setPositionFromRatio(btn, ratioX, ratioY);
return;
} catch {}
}
this.setPositionFromRatio(btn, 0.95, 0.85);
},
setPositionFromRatio(btn, ratioX, ratioY) {
const maxX = window.innerWidth - CONFIG.BUTTON_SIZE;
const maxY = window.innerHeight - CONFIG.BUTTON_SIZE;
const x = Math.max(0, Math.min(maxX, ratioX * maxX));
const y = Math.max(0, Math.min(maxY, ratioY * maxY));
btn.style.left = `${x}px`;
btn.style.top = `${y}px`;
btn.style.right = 'auto';
btn.style.bottom = 'auto';
},
applyRatioPosition() {
if (!this.btn) return;
const saved = localStorage.getItem(CONFIG.STORAGE_KEY);
if (saved) {
try {
const { ratioX, ratioY } = JSON.parse(saved);
this.setPositionFromRatio(this.btn, ratioX, ratioY);
} catch {}
}
},
savePositionAsRatio(x, y) {
const maxX = window.innerWidth - CONFIG.BUTTON_SIZE;
const maxY = window.innerHeight - CONFIG.BUTTON_SIZE;
const ratioX = maxX > 0 ? x / maxX : 0.95;
const ratioY = maxY > 0 ? y / maxY : 0.85;
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({
ratioX: Math.max(0, Math.min(1, ratioX)),
ratioY: Math.max(0, Math.min(1, ratioY))
}));
},
startDrag(e) {
const btn = this.btn;
if (!btn) return;
this.isDragging = true;
const rect = btn.getBoundingClientRect();
const offX = e.clientX - rect.left;
const offY = e.clientY - rect.top;
btn.style.cursor = 'grabbing';
btn.style.transform = 'scale(1.1)';
const onMove = (ev) => {
const maxX = window.innerWidth - CONFIG.BUTTON_SIZE;
const maxY = window.innerHeight - CONFIG.BUTTON_SIZE;
const x = Math.max(0, Math.min(maxX, ev.clientX - offX));
const y = Math.max(0, Math.min(maxY, ev.clientY - offY));
btn.style.left = `${x}px`;
btn.style.top = `${y}px`;
btn.style.right = 'auto';
btn.style.bottom = 'auto';
};
const onUp = () => {
btn.style.cursor = 'pointer';
btn.style.transform = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const finalRect = btn.getBoundingClientRect();
this.savePositionAsRatio(finalRect.left, finalRect.top);
setTimeout(() => {
this.isDragging = false;
}, 50);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
update() {
if (!this.btn) return;
let color, title;
if (this.isExporting) {
color = CONFIG.BUTTON_COLOR_LOADING;
title = 'Downloading...';
this.btn.style.opacity = '0.8';
this.btn.style.pointerEvents = 'none';
} else if (State.status === 'error') {
color = CONFIG.BUTTON_COLOR_ERROR;
title = `Error: ${State.error}\nClick to retry`;
this.btn.style.opacity = '1';
this.btn.style.pointerEvents = 'auto';
} else if (State.status === 'ready' && State.currentChatId) {
color = CONFIG.BUTTON_COLOR_READY;
title = 'Export Chat (with model names)\nLeft-click: Download\nRight-click drag: Move';
this.btn.style.opacity = '1';
this.btn.style.pointerEvents = 'auto';
} else {
color = CONFIG.BUTTON_COLOR_LOADING;
title = 'Waiting for chat...';
this.btn.style.opacity = '0.6';
this.btn.style.pointerEvents = 'auto';
}
this.btn.style.backgroundColor = color;
this.btn.title = title;
},
show() {
if (this.btn) {
this.btn.style.display = 'flex';
this.applyRatioPosition();
}
},
hide() {
if (this.btn) this.btn.style.display = 'none';
},
async export() {
const chatId = Router.getCurrentChatId();
if (!chatId) {
this.toast('No chat to export');
return;
}
if (this.isExporting) {
this.toast('Already downloading...');
return;
}
this.isExporting = true;
this.update();
this.toast('Fetching latest messages...');
try {
const chatData = await API.fetchChat(chatId);
const { content, filename } = generateMarkdown(chatData);
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
// Count model names found
const modelCount = ModelExtractor.getModelNamesFromDOM().length;
const assistantCount = chatData.messages.filter(m => m.role === 'assistant').length;
let statusMsg = `Exported ${chatData.messages.length} messages`;
if (CONFIG.INCLUDE_MODEL_NAMES && modelCount > 0) {
statusMsg += ` (${modelCount}/${assistantCount} models detected)`;
}
this.toast(statusMsg);
State.status = 'ready';
State.error = null;
} catch (err) {
State.status = 'error';
State.error = err.message;
this.toast(`Export failed: ${err.message}`);
} finally {
this.isExporting = false;
this.update();
}
},
toast(msg) {
const existing = document.getElementById('lma-toast');
if (existing) existing.remove();
const el = document.createElement('div');
el.id = 'lma-toast';
el.textContent = msg;
Object.assign(el.style, {
position: 'fixed',
bottom: '100px',
left: '50%',
transform: 'translateX(-50%)',
background: '#1f2937',
color: '#fff',
padding: '12px 24px',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'system-ui, sans-serif',
zIndex: CONFIG.Z_INDEX,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
opacity: '0',
transition: 'opacity 0.2s'
});
document.body.appendChild(el);
requestAnimationFrame(() => el.style.opacity = '1');
setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => el.remove(), 200);
}, 2500);
}
};
// ══════════════════════════════════════════════════════════════════════════
// BOOTSTRAP
// ══════════════════════════════════════════════════════════════════════════
const waitForBody = () => {
if (document.body) {
Router.init();
UI.init();
} else {
requestAnimationFrame(waitForBody);
}
};
waitForBody();
})();