// ==UserScript== // @name Claude Chat Exporter // @namespace lugia19.com // @match https://claude.ai/* // @version 2.1.3 // @author lugia19 // @license GPLv3 // @description Allows exporting chat conversations from claude.ai. // @grant GM_getValue // @grant GM_setValue // @downloadURL https://update.greasyfork.icu/scripts/515448/Claude%20Chat%20Exporter.user.js // @updateURL https://update.greasyfork.icu/scripts/515448/Claude%20Chat%20Exporter.meta.js // ==/UserScript== (function () { 'use strict'; function getConversationId() { const match = window.location.pathname.match(/\/chat\/([^/?]+)/); return match ? match[1] : null; } function createExportButton() { const button = document.createElement('button'); button.className = `inline-flex items-center justify-center relative shrink-0 ring-offset-2 ring-offset-bg-300 ring-accent-main-100 focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none text-text-200 border-transparent transition-colors font-styrene active:bg-bg-400 hover:bg-bg-500/40 hover:text-text-100 h-9 w-9 rounded-md active:scale-95 shrink-0`; button.innerHTML = ` `; // Add tooltip wrapper div const tooltipWrapper = document.createElement('div'); tooltipWrapper.setAttribute('data-radix-popper-content-wrapper', ''); tooltipWrapper.style.cssText = ` position: fixed; left: 0px; top: 0px; min-width: max-content; --radix-popper-transform-origin: 50% 0px; z-index: 50; display: none; `; // Add tooltip content tooltipWrapper.innerHTML = `
Export chatlog Export chatlog
`; // Add hover events button.addEventListener('mouseenter', () => { tooltipWrapper.style.display = 'block'; const rect = button.getBoundingClientRect(); const tooltipRect = tooltipWrapper.getBoundingClientRect(); const centerX = rect.left + (rect.width / 2) - (tooltipRect.width / 2); tooltipWrapper.style.transform = `translate(${centerX}px, ${rect.bottom + 5}px)`; }); button.addEventListener('mouseleave', () => { tooltipWrapper.style.display = 'none'; }); button.onclick = async () => { // Show format selection modal const { format, extension, exportTree } = await showFormatModal(); if (!format) return; const conversationData = await getMessages(exportTree); const conversationId = getConversationId(); const filename = `Claude_export_${conversationData.title}_${conversationId}.${extension}`; const content = await formatExport(conversationData, format, conversationId); downloadFile(filename, content); }; // Add tooltip to document document.body.appendChild(tooltipWrapper); return button; } async function showFormatModal() { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; // Get last used format, defaulting to txt_txt if none saved const lastFormat = GM_getValue('lastExportFormat', 'txt_txt'); modal.innerHTML = `

Export Format

`; document.body.appendChild(modal); return new Promise((resolve) => { const select = modal.querySelector('select'); const treeOption = modal.querySelector('#treeOption'); const checkbox = treeOption.querySelector('input[type="checkbox"]'); // Set the last used format select.value = lastFormat; // Show/hide tree option based on initial value const initialFormat = lastFormat.split('_')[0]; treeOption.classList.toggle('hidden', !['librechat', 'raw'].includes(initialFormat)); select.onchange = () => { const format = select.value.split('_')[0]; treeOption.classList.toggle('hidden', !['librechat', 'raw'].includes(format)); }; modal.querySelector('#cancelExport').onclick = () => { modal.remove(); resolve(null); }; modal.querySelector('#confirmExport').onclick = () => { // Save the selected format GM_setValue('lastExportFormat', select.value); const parts = select.value.split("_"); modal.remove(); resolve({ format: parts[0], extension: parts[1], exportTree: checkbox.checked }); }; modal.onclick = (e) => { if (e.target === modal) { modal.remove(); resolve(null); } }; }); } function getOrgId() { const cookies = document.cookie.split(';'); for (const cookie of cookies) { const [name, value] = cookie.trim().split('='); if (name === 'lastActiveOrg') { return value; } } throw new Error('Could not find organization ID'); } async function getMessages(fullTree = false) { const conversationId = getConversationId(); if (!conversationId) { throw new Error('Not in a conversation'); } const orgId = getOrgId(); const response = await fetch(`/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=${fullTree}&rendering_mode=messages&render_all_tools=true`); const conversationData = await response.json(); const messages = []; for (const message of conversationData.chat_messages) { let messageContent = []; for (const content of message.content) { messageContent = messageContent.concat(await getTextFromContent(content)); } messages.push({ role: message.sender === 'human' ? 'user' : 'assistant', content: messageContent.join('\n') }); } return { title: conversationData.name, updated_at: conversationData.updated_at, messages: messages, raw: conversationData }; } async function getTextFromContent(content) { let textPieces = []; if (content.text) { textPieces.push(content.text); } if (content.input) { textPieces.push(JSON.stringify(content.input)); } if (content.content) { // Handle nested content array if (Array.isArray(content.content)) { for (const nestedContent of content.content) { textPieces = textPieces.concat(await getTextFromContent(nestedContent)); } } // Handle single nested content object else if (typeof content.content === 'object') { textPieces = textPieces.concat(await getTextFromContent(content.content)); } } return textPieces; } async function formatExport(conversationData, format, conversationId) { const { title, updated_at, messages } = conversationData; switch (format) { case 'txt': return `Title: ${title}\nDate: ${updated_at}\n\n` + messages.map(msg => { const role = msg.role === 'user' ? 'User' : 'Assistant'; return `[${role}]\n${msg.content}\n`; }).join('\n'); case 'jsonl': return messages.map(JSON.stringify).join('\n'); case 'librechat': // First, process all messages' content const processedMessages = await Promise.all(conversationData.raw.chat_messages.map(async (msg) => { const contentText = []; for (const content of msg.content) { contentText.push(...await getTextFromContent(content)); } return { messageId: msg.uuid, parentMessageId: msg.parent_message_uuid === "00000000-0000-4000-8000-000000000000" ? null : msg.parent_message_uuid, text: contentText.join('\n'), sender: msg.sender === "assistant" ? "Claude" : "User", isCreatedByUser: msg.sender === "human", createdAt: msg.created_at }; })); // Then create and return the final object return JSON.stringify({ title: conversationData.raw.name, endpoint: "anthropic", conversationId: conversationId, options: { model: conversationData.raw.model ?? "claude-3-5-sonnet-latest" }, messages: processedMessages }, null, 2); case 'raw': return JSON.stringify(conversationData.raw, null, 2); default: throw new Error(`Unsupported format: ${format}`); } } function downloadFile(filename, content) { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function initialize() { // Try to add the button immediately tryAddButton(); // Also check every 5 seconds setInterval(tryAddButton, 5000); } function tryAddButton() { const container = document.querySelector(".right-3.flex.gap-2"); if (!container || container.querySelector('.export-button') || container.querySelectorAll("button").length == 0) { return; // Either container not found or button already exists } const exportButton = createExportButton(); exportButton.classList.add('export-button'); // Add class to check for existence container.insertBefore(exportButton, container.firstChild); } initialize(); })();