From c7469ce884f8061244b35cdf121578ffd573d703 Mon Sep 17 00:00:00 2001 From: Jakub Fidler <31575114+RisingOrange@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:37:52 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20Claude=20conversation?= =?UTF-8?q?=20importer=20with=20thinking=20support=20(#11124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add Claude conversation importer with thinking support Add support for importing Claude conversation exports: - Detect Claude format by checking for chat_messages property - Extract text and thinking content from content array - Format thinking blocks using LibreChat's { type: 'think' } format - Preserve timestamps from original conversation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * ✨ feat: Improve Claude importer with tests and timestamp handling - Remove hardcoded model (Claude exports don't include model info) - Add timestamp ordering to ensure parents appear before children - Add fallback to conv.created_at for null message timestamps - Add comprehensive tests for Claude importer: - Basic import, thinking content, timestamp handling - Empty messages, text fallback, default conversation name 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- api/server/utils/import/importers.js | 113 +++++++- api/server/utils/import/importers.spec.js | 298 ++++++++++++++++++++++ 2 files changed, 410 insertions(+), 1 deletion(-) diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index dc32b018e7..81a0f048df 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -13,8 +13,14 @@ const getLogStores = require('~/cache/getLogStores'); * @throws {Error} - If the import type is not supported. */ function getImporter(jsonData) { - // For ChatGPT + // For array-based formats (ChatGPT or Claude) if (Array.isArray(jsonData)) { + // Claude format has chat_messages array in each conversation + if (jsonData.length > 0 && jsonData[0]?.chat_messages) { + logger.info('Importing Claude conversation'); + return importClaudeConvo; + } + // ChatGPT format has mapping object in each conversation logger.info('Importing ChatGPT conversation'); return importChatGptConvo; } @@ -71,6 +77,111 @@ async function importChatBotUiConvo( } } +/** + * Extracts text and thinking content from a Claude message. + * @param {Object} msg - Claude message object with content array and optional text field. + * @returns {{textContent: string, thinkingContent: string}} Extracted text and thinking content. + */ +function extractClaudeContent(msg) { + let textContent = ''; + let thinkingContent = ''; + + for (const part of msg.content || []) { + if (part.type === 'text' && part.text) { + textContent += part.text; + } else if (part.type === 'thinking' && part.thinking) { + thinkingContent += part.thinking; + } + } + + // Use the text field as fallback if content array is empty + if (!textContent && msg.text) { + textContent = msg.text; + } + + return { textContent, thinkingContent }; +} + +/** + * Imports Claude conversations from provided JSON data. + * Claude export format: array of conversations with chat_messages array. + * + * @param {Array} jsonData - Array of Claude conversation objects to be imported. + * @param {string} requestUserId - The ID of the user who initiated the import process. + * @param {Function} builderFactory - Factory function to create a new import batch builder instance. + * @returns {Promise} Promise that resolves when all conversations have been imported. + */ +async function importClaudeConvo( + jsonData, + requestUserId, + builderFactory = createImportBatchBuilder, +) { + try { + const importBatchBuilder = builderFactory(requestUserId); + + for (const conv of jsonData) { + importBatchBuilder.startConversation(EModelEndpoint.anthropic); + + let lastMessageId = Constants.NO_PARENT; + let lastTimestamp = null; + + for (const msg of conv.chat_messages || []) { + const isCreatedByUser = msg.sender === 'human'; + const messageId = uuidv4(); + + const { textContent, thinkingContent } = extractClaudeContent(msg); + + // Skip empty messages + if (!textContent && !thinkingContent) { + continue; + } + + // Parse timestamp, fallback to conversation create_time or current time + const messageTime = msg.created_at || conv.created_at; + let createdAt = messageTime ? new Date(messageTime) : new Date(); + + // Ensure timestamp is after the previous message. + // Messages are sorted by createdAt and buildTree expects parents to appear before children. + // This guards against any potential ordering issues in exports. + if (lastTimestamp && createdAt <= lastTimestamp) { + createdAt = new Date(lastTimestamp.getTime() + 1); + } + lastTimestamp = createdAt; + + const message = { + messageId, + parentMessageId: lastMessageId, + text: textContent, + sender: isCreatedByUser ? 'user' : 'Claude', + isCreatedByUser, + user: requestUserId, + endpoint: EModelEndpoint.anthropic, + createdAt, + }; + + // Add content array with thinking if present + if (thinkingContent && !isCreatedByUser) { + message.content = [ + { type: 'think', think: thinkingContent }, + { type: 'text', text: textContent }, + ]; + } + + importBatchBuilder.saveMessage(message); + lastMessageId = messageId; + } + + const createdAt = conv.created_at ? new Date(conv.created_at) : new Date(); + importBatchBuilder.finishConversation(conv.name || 'Imported Claude Chat', createdAt); + } + + await importBatchBuilder.saveBatch(); + logger.info(`user: ${requestUserId} | Claude conversation imported`); + } catch (error) { + logger.error(`user: ${requestUserId} | Error creating conversation from Claude file`, error); + } +} + /** * Imports a LibreChat conversation from JSON. * diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index 7c284540e6..a695a31555 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -1313,3 +1313,301 @@ describe('processAssistantMessage', () => { expect(duration).toBeLessThan(100); }); }); + +describe('importClaudeConvo', () => { + it('should import basic Claude conversation correctly', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'Test Conversation', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello Claude' }], + }, + { + uuid: 'msg-2', + sender: 'assistant', + created_at: '2025-01-15T10:00:02.000Z', + content: [{ type: 'text', text: 'Hello! How can I help you?' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + jest.spyOn(importBatchBuilder, 'startConversation'); + jest.spyOn(importBatchBuilder, 'finishConversation'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.anthropic); + expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(2); + expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( + 'Test Conversation', + expect.any(Date), + ); + + const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); + + // Check user message + const userMsg = savedMessages.find((msg) => msg.text === 'Hello Claude'); + expect(userMsg.isCreatedByUser).toBe(true); + expect(userMsg.sender).toBe('user'); + expect(userMsg.endpoint).toBe(EModelEndpoint.anthropic); + + // Check assistant message + const assistantMsg = savedMessages.find((msg) => msg.text === 'Hello! How can I help you?'); + expect(assistantMsg.isCreatedByUser).toBe(false); + expect(assistantMsg.sender).toBe('Claude'); + expect(assistantMsg.parentMessageId).toBe(userMsg.messageId); + }); + + it('should merge thinking content into assistant message', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'Thinking Test', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'What is 2+2?' }], + }, + { + uuid: 'msg-2', + sender: 'assistant', + created_at: '2025-01-15T10:00:02.000Z', + content: [ + { type: 'thinking', thinking: 'Let me calculate this simple math problem.' }, + { type: 'text', text: 'The answer is 4.' }, + ], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); + const assistantMsg = savedMessages.find((msg) => msg.text === 'The answer is 4.'); + + expect(assistantMsg.content).toBeDefined(); + expect(assistantMsg.content).toHaveLength(2); + expect(assistantMsg.content[0].type).toBe('think'); + expect(assistantMsg.content[0].think).toBe('Let me calculate this simple math problem.'); + expect(assistantMsg.content[1].type).toBe('text'); + expect(assistantMsg.content[1].text).toBe('The answer is 4.'); + }); + + it('should not include model field (Claude exports do not contain model info)', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'No Model Test', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); + // Model should not be explicitly set (will use ImportBatchBuilder default) + expect(savedMessages[0]).not.toHaveProperty('model'); + }); + + it('should correct timestamp inversions (child before parent)', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'Timestamp Inversion Test', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:05.000Z', // Later timestamp + content: [{ type: 'text', text: 'First message' }], + }, + { + uuid: 'msg-2', + sender: 'assistant', + created_at: '2025-01-15T10:00:02.000Z', // Earlier timestamp (inverted) + content: [{ type: 'text', text: 'Second message' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); + const firstMsg = savedMessages.find((msg) => msg.text === 'First message'); + const secondMsg = savedMessages.find((msg) => msg.text === 'Second message'); + + // Second message should have timestamp adjusted to be after first + expect(new Date(secondMsg.createdAt).getTime()).toBeGreaterThan( + new Date(firstMsg.createdAt).getTime(), + ); + }); + + it('should use conversation create_time for null message timestamps', async () => { + const convCreateTime = '2025-01-15T10:00:00.000Z'; + const jsonData = [ + { + uuid: 'conv-123', + name: 'Null Timestamp Test', + created_at: convCreateTime, + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: null, // Null timestamp + content: [{ type: 'text', text: 'Message with null time' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); + expect(savedMessages[0].createdAt).toEqual(new Date(convCreateTime)); + }); + + it('should use text field as fallback when content array is empty', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'Text Fallback Test', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + text: 'Fallback text content', + content: [], // Empty content array + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); + expect(savedMessages[0].text).toBe('Fallback text content'); + }); + + it('should skip empty messages', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'Skip Empty Test', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Valid message' }], + }, + { + uuid: 'msg-2', + sender: 'assistant', + created_at: '2025-01-15T10:00:02.000Z', + content: [], // Empty content + text: '', // Empty text + }, + { + uuid: 'msg-3', + sender: 'human', + created_at: '2025-01-15T10:00:03.000Z', + content: [{ type: 'text', text: 'Another valid message' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + // Should only save 2 messages (empty one skipped) + expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(2); + }); + + it('should use default name for unnamed conversations', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: '', // Empty name + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'finishConversation'); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( + 'Imported Claude Chat', + expect.any(Date), + ); + }); +});