// ==UserScript== // @name Claude Forking // @namespace https://lugia19.com // @version 0.1 // @description Adds forking functionality to claude.ai // @match https://claude.ai/* // @grant none // @license GPLv3 // @downloadURL none // ==/UserScript== (function () { 'use strict'; let pendingForkModel = null; let isProcessing = false; //#region UI elements creation function createBranchButton() { const button = document.createElement('button'); button.className = 'branch-button flex flex-row items-center gap-1 rounded-md p-1 py-0.5 text-xs transition-opacity delay-100 hover:bg-bg-200 group/button'; button.innerHTML = ` Fork `; button.onclick = (e) => { e.preventDefault(); e.stopPropagation(); const modal = createModal(); document.body.appendChild(modal); // Add event listeners modal.querySelector('#cancelFork').onclick = () => { modal.remove(); }; // And in our modal click handler: modal.querySelector('#confirmFork').onclick = () => { const model = modal.querySelector('select').value; forkConversationClicked(model, button); // Pass the fork button as context modal.remove(); }; // Click outside to cancel modal.onclick = (e) => { if (e.target === modal) { modal.remove(); } }; }; return button; } function createModal() { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; modal.innerHTML = `

Choose Model for Fork

Note: Should you choose a slow model such as Opus, you may need to wait and refresh the page for the response to appear.

`; return modal; } function findMessageControls(messageElement) { if (messageElement.classList.contains('font-user-message')) { const group = messageElement.closest('.group'); const buttons = group?.querySelectorAll('button'); if (!buttons) return; const editButton = Array.from(buttons).find(button => button.textContent.includes('Edit') ); return editButton?.closest('.text-text-400.flex'); } if (messageElement.classList.contains('font-claude-message')) { const group = messageElement.closest('.group'); const buttons = group?.querySelectorAll('button'); const retryButton = Array.from(buttons).find(button => button.textContent.includes('Retry') ); return retryButton?.closest('.text-text-400.flex'); } return null; } function addBranchButtons() { if (isProcessing) return; try { isProcessing = true; const messages = document.querySelectorAll('.font-claude-message'); //Only add to claude messages, as we leverage the retry button. messages.forEach((message) => { const controls = findMessageControls(message); if (controls && !controls.querySelector('.branch-button')) { const branchBtn = createBranchButton(); controls.insertBefore(branchBtn, controls.firstChild); } }); } catch (error) { console.error('Error adding branch buttons:', error); } finally { isProcessing = false; } } //#endregion function forkConversationClicked(model, forkButton) { // Get conversation ID from URL const conversationId = window.location.pathname.split('/').pop(); console.log('Forking conversation', conversationId, 'with model', model); // Set up our global to catch the next retry request pendingForkModel = model; // Find and click the retry button in the same control group as our fork button const buttonGroup = forkButton.closest('.text-text-400.flex'); const retryButton = Array.from(buttonGroup.querySelectorAll('button')) .find(button => button.textContent.includes('Retry')); if (retryButton) { retryButton.click(); } else { console.error('Could not find retry button'); } } //#region Convo extraction async function getConversationContext(orgId, conversationId, targetParentUuid) { const response = await fetch(`/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=False&rendering_mode=messages&render_all_tools=true`); const conversationData = await response.json(); let messages = []; let projectUuid = conversationData.project_uuid || null; const chatName = conversationData.name; const files = [] const syncsources = [] const attachments = [] for (const message of conversationData.chat_messages) { let messageContent = []; // Process content array for (const content of message.content) { if (content.text) { messageContent.push(content.text); } if (content.input?.code) { messageContent.push(content.input.code); } if (content.content?.text) { messageContent.push(content.content.text); } } // Process files with download URLs if (message.files_v2) { for (const file of message.files_v2) { let fileUrl; if (file.file_kind === "image") { fileUrl = file.preview_asset.url; } else if (file.file_kind === "document") { fileUrl = file.document_asset.url; } if (fileUrl) { files.push({ uuid: file.file_uuid, url: fileUrl, kind: file.file_kind, name: file.file_name }); } } } // Add attachment objects if (message.attachments) { for (const attachment of message.attachments) { attachments.push(attachment); } } // Process sync sources for (const sync of message.sync_sources) { syncsources.push(sync?.config?.uri); } messages.push(messageContent.join(' ')); // Process until we find a message that has our target UUID as parent if (message.parent_message_uuid === targetParentUuid) { break; } } return { chatName, messages, syncsources, attachments, files, projectUuid }; } //#region File handlers (download, upload, sync) async function downloadFiles(files) { const downloadedFiles = []; for (const file of files) { try { const response = await fetch(file.url); const blob = await response.blob(); downloadedFiles.push({ data: blob, name: file.name, kind: file.kind, originalUuid: file.uuid }); } catch (error) { console.error(`Failed to download file ${file.name}:`, error); } } return downloadedFiles; } async function uploadFile(orgId, file) { const formData = new FormData(); formData.append('file', file.data, file.name); const response = await fetch(`/api/${orgId}/upload`, { method: 'POST', body: formData }); const uploadResult = await response.json(); return uploadResult.file_uuid; } async function processSyncSource(orgId, uri) { const response = await fetch(`/api/organizations/${orgId}/sync/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sync_source_config: { uri: uri }, sync_source_type: "gdrive" }) }); const result = await response.json(); return result.uuid; } //#endregion //#region Convo forking function generateUuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } async function createForkedConversation(orgId, context, model, styleData) { const newUuid = generateUuid(); const newName = `Fork of ${context.chatName}`; const chatlog = context.messages.map((msg, index) => { const role = index % 2 === 0 ? 'User' : 'Assistant'; return `${role}\n${msg}`; }).join('\n\n'); context.attachments.push({ "extracted_content": chatlog, "file_name": "chatlog.txt", "file_size": 0, "file_type": "text/plain" }) const createResponse = await fetch(`/api/organizations/${orgId}/chat_conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uuid: newUuid, name: newName, model: model, include_conversation_preferences: true }) }); if (!createResponse.ok) { throw new Error('Failed to create conversation'); } // Send initial message to set up conversation history const completionResponse = await fetch(`/api/organizations/${orgId}/chat_conversations/${newUuid}/completion`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: "This conversation is forked from the attached chatlog.txt\nYou are Assistant. Simply say 'Acknowledged' and wait for user input.", model: model, parent_message_uuid: '00000000-0000-4000-8000-000000000000', attachments: context.attachments, files: context.files, sync_sources: context.syncsources, personalized_styles: styleData }) }); if (!completionResponse.ok) { throw new Error('Failed to initialize conversation'); } // Sleep for 2 seconds to allow the response to be fully created... await new Promise(r => setTimeout(r, 2000)); return newUuid; } //#endregion //#region Fetch patching const originalFetch = window.fetch; window.fetch = async (...args) => { const [input, config] = args; // Get the URL string whether it's a string or Request object let url = undefined if (input instanceof URL) { url = input.href } else if (typeof input === 'string') { url = input } else if (input instanceof Request) { url = input.url } if (url && url.includes('/retry_completion') && pendingForkModel) { console.log('Intercepted retry request:', config?.body); const bodyJSON = JSON.parse(config?.body); const messageID = bodyJSON?.parent_message_uuid; const urlParts = url.split('/'); const orgId = urlParts[urlParts.indexOf('organizations') + 1]; const conversationId = urlParts[urlParts.indexOf('chat_conversations') + 1]; let styleData = bodyJSON?.personalized_styles; try { // Get conversation context const context = await getConversationContext(orgId, conversationId, messageID); const downloadedFiles = await downloadFiles(context.files); // Parallel processing of files and syncs [context.files, context.syncsources] = await Promise.all([ Promise.all(downloadedFiles.map(file => uploadFile(orgId, file))), Promise.all(context.syncsources.map(uri => processSyncSource(orgId, uri))) ]); // Create forked conversation const newConversationId = await createForkedConversation(orgId, context, pendingForkModel, styleData); // Navigate to new conversation console.log('Forked conversation created:', newConversationId); window.location.href = `/chat/${newConversationId}`; } catch (error) { console.error('Failed to fork conversation:', error); } pendingForkModel = null; // Clear the pending flag return new Response(JSON.stringify({ success: true })); } return originalFetch(...args); }; //#endregion //Check for buttons every 3 seconds setInterval(addBranchButtons, 3000); })();