mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-02 00:28:51 +01:00
✨ 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:
parent
180d0f18fe
commit
c7469ce884
2 changed files with 410 additions and 1 deletions
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue