feat: Add Claude conversation importer with thinking support (#11124)

*  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 <noreply@anthropic.com>

*  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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jakub Fidler 2025-12-30 03:37:52 +01:00 committed by GitHub
parent 180d0f18fe
commit c7469ce884
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 410 additions and 1 deletions

View file

@ -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<void>} 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.
*

View file

@ -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),
);
});
});