diff --git a/api/server/routes/__tests__/convos-import.spec.js b/api/server/routes/__tests__/convos-import.spec.js index e58158cc20..7a0f2a411e 100644 --- a/api/server/routes/__tests__/convos-import.spec.js +++ b/api/server/routes/__tests__/convos-import.spec.js @@ -7,8 +7,13 @@ const mongoose = require('mongoose'); const multer = require('multer'); const { MongoMemoryServer } = require('mongodb-memory-server'); const { buildChatGptExportZip, cleanupChatGptExportZips } = require('~/test/chatgptExport'); +const { + bareClaudeExport, + buildClaudeExportZip, + cleanupClaudeExportZips, +} = require('~/test/claudeExport'); const { createModels, createMethods } = require('@librechat/data-schemas'); -const { FileSources } = require('librechat-data-provider'); +const { FileSources, EModelEndpoint } = require('librechat-data-provider'); jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()); jest.mock('~/server/middleware', () => ({ @@ -193,22 +198,6 @@ function bareChatGptExport() { ); } -function claudeExport() { - return Buffer.from( - JSON.stringify([ - { - uuid: 'claude-1', - name: 'Claude convo', - created_at: '2024-01-01T00:00:00Z', - chat_messages: [ - { sender: 'human', text: 'Hi there', created_at: '2024-01-01T00:00:00Z' }, - { sender: 'assistant', text: 'Hello!', created_at: '2024-01-01T00:00:01Z' }, - ], - }, - ]), - ); -} - function chatbotUiExport() { return Buffer.from( JSON.stringify({ @@ -275,6 +264,7 @@ describe('conversation import job API (real router, real Mongo)', () => { fs.rmSync(dir, { recursive: true, force: true }); } cleanupChatGptExportZips(); + cleanupClaudeExportZips(); }); beforeEach(async () => { @@ -355,15 +345,72 @@ describe('conversation import job API (real router, real Mongo)', () => { expect(savedConvos).toBe(1); }); - it('still imports a Claude-shaped .json upload through the legacy synchronous path', async () => { - const res = await request(app) - .post('/api/convos/import') - .attach('file', claudeExport(), 'claude-export.json') - .expect(201); + it('imports a Claude export .zip through the job API', async () => { + const filepath = await buildClaudeExportZip(); - expect(res.body.message).toBe('Conversation(s) imported successfully'); - const savedConvos = await Conversation.countDocuments({ user: userId }); - expect(savedConvos).toBe(1); + const uploaded = await request(app) + .post('/api/convos/import') + .attach('file', filepath) + .expect(202); + + expect(uploaded.body.summary).toMatchObject({ + source: 'claude', + conversations: 2, + assets: 0, + archived: 0, + starred: 0, + }); + + await request(app).post(`/api/convos/import/jobs/${uploaded.body.jobId}/start`).expect(202); + const completed = await waitForTerminal(app, uploaded.body.jobId); + + expect(completed.body.phase).toBe('completed'); + expect(completed.body.report.imported).toBe(2); + expect(completed.body.report.errors).toEqual([]); + + const convos = await Conversation.find({ user: userId }).lean(); + expect(convos).toHaveLength(2); + expect(convos.every((convo) => convo.endpoint === EModelEndpoint.anthropic)).toBe(true); + expect(convos.map((convo) => convo.importedFrom.source).sort()).toEqual(['claude', 'claude']); + }); + + it('imports a bare Claude conversations.json upload through the same job API', async () => { + const uploaded = await request(app) + .post('/api/convos/import') + .attach('file', bareClaudeExport(), 'conversations.json') + .expect(202); + + expect(uploaded.body.summary.source).toBe('claude'); + expect(uploaded.body.summary.conversations).toBe(2); + + await request(app).post(`/api/convos/import/jobs/${uploaded.body.jobId}/start`).expect(202); + const completed = await waitForTerminal(app, uploaded.body.jobId); + + expect(completed.body.phase).toBe('completed'); + expect(completed.body.report.imported).toBe(2); + expect(await Conversation.countDocuments({ user: userId })).toBe(2); + }); + + it('skips a Claude conversation already imported and does not skip ChatGPT ids', async () => { + const filepath = await buildClaudeExportZip(); + + const first = await request(app) + .post('/api/convos/import') + .attach('file', filepath) + .expect(202); + await request(app).post(`/api/convos/import/jobs/${first.body.jobId}/start`).expect(202); + await waitForTerminal(app, first.body.jobId); + + const second = await request(app) + .post('/api/convos/import') + .attach('file', await buildClaudeExportZip()) + .expect(202); + await request(app).post(`/api/convos/import/jobs/${second.body.jobId}/start`).expect(202); + const completed = await waitForTerminal(app, second.body.jobId); + + expect(completed.body.report.imported).toBe(0); + expect(completed.body.report.skipped).toBe(2); + expect(await Conversation.countDocuments({ user: userId })).toBe(2); }); it('still imports a ChatbotUI-shaped .json upload through the legacy synchronous path', async () => { diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index e5bb67ed99..df2b37233a 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -326,18 +326,37 @@ function handleUpload(req, res, next) { /** * Existing `importedFrom.externalId` values already saved for this user, used * to skip conversations a prior (interrupted or re-run) import already wrote. + * Scoped to the export's own source: a ChatGPT conversation id can never + * collide with a Claude one, and treating them as one namespace would let one + * provider's ids suppress the other's. * @param {string} userId + * @param {string} source - `importedFrom.source` of the export being imported. * @returns {Promise>} */ -async function loadExistingExternalIds(userId) { +async function loadExistingExternalIds(userId, source) { const Conversation = mongoose.models.Conversation; const rows = await Conversation.find( - { user: userId, 'importedFrom.source': 'chatgpt' }, + { user: userId, 'importedFrom.source': source }, { 'importedFrom.externalId': 1 }, ).lean(); return new Set(rows.map((row) => row.importedFrom?.externalId).filter(Boolean)); } +/** + * The converter, endpoint and `importedFrom.source` a confirmed job needs, + * derived from the summary `inspectExport` already recorded on it. Passing the + * format through means `runImport` never re-reads a shard just to re-learn a + * shape the inspect step already established. + * @param {import('@librechat/api').ImportJob} job + * @returns {{ format: string, endpoint: string, source: string }} + */ +function resolveJobTarget(job) { + if (job.summary?.source === 'claude') { + return { format: 'claude', endpoint: EModelEndpoint.anthropic, source: 'claude' }; + } + return { format: 'chatgpt', endpoint: EModelEndpoint.openAI, source: 'chatgpt' }; +} + /** * Runs a confirmed import job to completion in the background, updating the * job record as it progresses. Never throws: failures are recorded on the job @@ -356,9 +375,10 @@ async function runImportJob(req, job) { const source = getFileStrategy(appConfig, { isImage: true }); const { saveBuffer } = getStrategyFunctions(source); const batch = createImportBatchBuilder(req.user.id, appConfig?.interfaceConfig); + const target = resolveJobTarget(job); const defaultModel = await resolveImportDefaultModel({ - endpoint: EModelEndpoint.openAI, + endpoint: target.endpoint, requestUserId: req.user.id, userRole: req.user.role, }); @@ -368,10 +388,11 @@ async function runImportJob(req, job) { userId: req.user.id, tenantId: req.user.tenantId, source, + format: target.format, defaultModel, deps: { saveBuffer, createFile: db.createFile }, batch, - existingExternalIds: await loadExistingExternalIds(req.user.id), + existingExternalIds: await loadExistingExternalIds(req.user.id, target.source), isCancelled: () => importJobs.isCancelled(req.user.id, job.jobId), onProgress: async (progress) => { await importJobs.patch(req.user.id, job.jobId, { progress }); @@ -417,11 +438,12 @@ async function runImportJob(req, job) { } /** - * Imports a bare ChatGPT-legacy export synchronously through the old, - * un-jobbed importer. Reached only for uploads that are neither a zip nor - * recognizable ChatGPT content: Claude, ChatbotUI, and LibreChat exports - * are all detected by CONTENT (`getImporter`, invoked inside - * `importConversations`), not by file extension. + * Imports a bare export synchronously through the old, un-jobbed importer. + * Reached only for uploads that are neither a zip nor recognizable ChatGPT or + * Claude content: ChatbotUI and LibreChat exports are detected by CONTENT + * (`getImporter`, invoked inside `importConversations`), not by file extension. + * The `.zip` exclusion is load-bearing — this path reads `req.file.path` as + * JSON, which no archive can satisfy. * @param {object} req * @param {object} res * @returns {Promise} @@ -445,14 +467,14 @@ async function importLegacyConversation(req, res) { /** * Imports a conversation export and saves it to the database. * - * `.zip` uploads and bare `.json` ChatGPT exports share one pipeline: - * `openArchive` wraps a non-zip file in a single-entry archive, so + * `.zip` uploads and bare `.json` ChatGPT and Claude exports share one + * pipeline: `openArchive` wraps a non-zip file in a single-entry archive, so * `inspectExport` sees the same shape either way and this request only * inspects the upload and returns a summary awaiting confirmation — the * actual import runs after POST /import/jobs/:jobId/start. A bare `.json` - * upload that is not ChatGPT-shaped (Claude, ChatbotUI, LibreChat) falls - * back to the legacy synchronous importer, since `inspectExport`/`runImport` - * only understand the ChatGPT export layout. + * upload that is neither ChatGPT- nor Claude-shaped (ChatbotUI, LibreChat) + * falls back to the legacy synchronous importer, which is the only path that + * understands those layouts. * @route POST /import * @param {Express.Multer.File} req.file - The uploaded export file. * @returns {object} 201 - legacy (non-ChatGPT) JSON import succeeded diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index c71cf283eb..a8699bdfbf 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -1,7 +1,11 @@ -const { v4: uuidv4 } = require('uuid'); -const { convertConversation } = require('@librechat/api'); +const { convertConversation, convertClaudeConversation } = require('@librechat/api'); const { logger, getTenantId } = require('@librechat/data-schemas'); -const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); +const { + Constants, + EModelEndpoint, + openAISettings, + anthropicSettings, +} = require('librechat-data-provider'); const { getEndpointsConfig } = require('~/server/services/Config'); const { createImportBatchBuilder } = require('./importBatchBuilder'); const { resolveImportDefaultModel } = require('./defaults'); @@ -89,38 +93,19 @@ 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. + * Delegates conversion of each conversation to `convertClaudeConversation`, the + * same engine `runImport` uses for zipped Claude exports, so a bare + * `conversations.json` upload and a zip archive produce identical messages — + * branching, tool calls, reasoning, attachment extractions and citations + * included. A Claude export ships no binaries in either shape, so nothing is + * lost by this path not resolving assets. * * @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. + * @param {string} [userRole] - The role of the importing user. * @returns {Promise} Promise that resolves when all conversations have been imported. */ async function importClaudeConvo( @@ -138,63 +123,24 @@ async function importClaudeConvo( }); for (const conv of jsonData) { + const converted = convertClaudeConversation(conv, { + defaultModel: defaultModel || anthropicSettings.model.default, + }); + 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; + for (const message of converted.messages) { + importBatchBuilder.saveMessage(toSaveMessageDetails(message, EModelEndpoint.anthropic)); } - - const createdAt = conv.created_at ? new Date(conv.created_at) : new Date(); importBatchBuilder.finishConversation( - conv.name || 'Imported Claude Chat', - createdAt, - {}, - defaultModel, + converted.title, + converted.createdAt, + { + isArchived: converted.isArchived, + pinned: converted.pinned, + model: converted.model, + importedFrom: { source: 'claude', externalId: converted.externalId }, + }, + converted.model, ); } @@ -317,14 +263,15 @@ async function importLibreChatConvo( /** * Builds the payload `ImportBatchBuilder.saveMessage` expects from one - * message produced by the shared `convertConversation` engine (the same - * conversion used for zipped ChatGPT export imports), so a bare `.json` - * upload and a zip archive share one conversion implementation. + * message produced by a shared conversion engine (the same conversions used + * for zipped ChatGPT and Claude export imports), so a bare `.json` upload and + * a zip archive share one conversion implementation. * * @param {import('@librechat/api').ConvertedMessage} message + * @param {string} endpoint * @returns {object} */ -function toSaveMessageDetails(message) { +function toSaveMessageDetails(message, endpoint) { return { messageId: message.messageId, parentMessageId: message.parentMessageId, @@ -333,7 +280,7 @@ function toSaveMessageDetails(message) { isCreatedByUser: message.isCreatedByUser, model: message.model, createdAt: message.createdAt, - endpoint: EModelEndpoint.openAI, + endpoint, content: message.content, attachments: message.attachments, files: message.files, @@ -377,7 +324,7 @@ async function importChatGptConvo( importBatchBuilder.startConversation(EModelEndpoint.openAI); for (const message of converted.messages) { - importBatchBuilder.saveMessage(toSaveMessageDetails(message)); + importBatchBuilder.saveMessage(toSaveMessageDetails(message, EModelEndpoint.openAI)); } importBatchBuilder.finishConversation( converted.title, diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index b6635c3ff5..d6f7ccebdd 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -1266,7 +1266,12 @@ describe('importClaudeConvo', () => { expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( 'Test Conversation', expect.any(Date), - {}, + { + isArchived: false, + pinned: false, + model: expect.any(String), + importedFrom: { source: 'claude', externalId: 'conv-123' }, + }, expect.any(String), ); @@ -1329,7 +1334,7 @@ describe('importClaudeConvo', () => { expect(assistantMsg.content[1].text).toBe('The answer is 4.'); }); - it('should not include model field (Claude exports do not contain model info)', async () => { + it('should save messages with the resolved anthropic model (Claude exports carry none)', async () => { const jsonData = [ { uuid: 'conv-123', @@ -1354,8 +1359,7 @@ describe('importClaudeConvo', () => { 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'); + expect(savedMessages[0].model).toBe(anthropicSettings.model.default); }); it('should set the conversation endpoint and a Claude model so the chat UI loads correctly without a refresh', async () => { @@ -1654,7 +1658,9 @@ describe('importClaudeConvo', () => { expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( 'Imported Claude Chat', expect.any(Date), - {}, + expect.objectContaining({ + importedFrom: { source: 'claude', externalId: 'conv-123' }, + }), expect.any(String), ); }); diff --git a/api/test/claudeExport.js b/api/test/claudeExport.js new file mode 100644 index 0000000000..98bec0ec11 --- /dev/null +++ b/api/test/claudeExport.js @@ -0,0 +1,111 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const JSZip = require('jszip'); + +const createdDirs = []; + +/** The all-zero uuid a real Claude export uses to root a message. */ +const ROOT_UUID = '00000000-0000-4000-8000-000000000000'; + +const CONVERSATIONS = [ + { + uuid: 'claude-branched', + name: 'Branching answer', + created_at: '2025-12-08T17:00:00.000Z', + updated_at: '2025-12-08T17:05:00.000Z', + chat_messages: [ + { + uuid: 'm1', + sender: 'human', + parent_message_uuid: ROOT_UUID, + created_at: '2025-12-08T17:00:01.000Z', + content: [{ type: 'text', text: 'Which town?' }], + }, + { + uuid: 'm2a', + sender: 'assistant', + parent_message_uuid: 'm1', + created_at: '2025-12-08T17:00:02.000Z', + content: [{ type: 'text', text: 'Positano.' }], + }, + { + uuid: 'm2b', + sender: 'assistant', + parent_message_uuid: 'm1', + created_at: '2025-12-08T17:00:03.000Z', + content: [{ type: 'text', text: 'Ravello.' }], + }, + ], + }, + { + uuid: 'claude-tools', + name: 'Tooling run', + created_at: '2025-12-09T09:00:00.000Z', + chat_messages: [ + { + uuid: 't1', + sender: 'human', + parent_message_uuid: null, + created_at: '2025-12-09T09:00:01.000Z', + content: [{ type: 'text', text: 'Run it' }], + }, + { + uuid: 't2', + sender: 'assistant', + parent_message_uuid: 't1', + created_at: '2025-12-09T09:00:02.000Z', + content: [ + { + type: 'tool_use', + id: 'tu-1', + name: 'bash_tool', + input: { command: 'ls -la' }, + }, + { + type: 'tool_result', + tool_use_id: 'tu-1', + name: 'bash_tool', + content: [{ type: 'text', text: 'src' }], + }, + ], + }, + ], + }, +]; + +/** The conversations array on its own, as a bare `conversations.json` upload. */ +function bareClaudeExport() { + return Buffer.from(JSON.stringify(CONVERSATIONS)); +} + +/** + * Minimal Claude export archive for route-level tests: `conversations.json` + * plus the out-of-scope entries a real export ships, and no binaries at all. + * @returns {Promise} path to the written zip + */ +async function buildClaudeExportZip() { + const zip = new JSZip(); + + zip.file('conversations.json', JSON.stringify(CONVERSATIONS)); + zip.file('users.json', JSON.stringify([{ uuid: 'u-1', email: 'fixture@example.com' }])); + zip.file('memories.json', JSON.stringify([])); + zip.file('projects/p-1.json', JSON.stringify({ uuid: 'p-1', name: 'Project' })); + zip.file('design_chats/d-1.json', JSON.stringify({ uuid: 'd-1' })); + + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-import-claude-route-')); + createdDirs.push(dir); + const filepath = path.join(dir, 'claude-export.zip'); + fs.writeFileSync(filepath, buffer); + return filepath; +} + +function cleanupClaudeExportZips() { + while (createdDirs.length > 0) { + const dir = createdDirs.pop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +module.exports = { bareClaudeExport, buildClaudeExportZip, cleanupClaudeExportZips }; diff --git a/packages/api/src/import/claude/convert.spec.ts b/packages/api/src/import/claude/convert.spec.ts index b261693db9..b4a3c42e6c 100644 --- a/packages/api/src/import/claude/convert.spec.ts +++ b/packages/api/src/import/claude/convert.spec.ts @@ -330,6 +330,32 @@ describe('convertClaudeConversation', () => { expect(converted.messages[0].createdAt.toISOString()).toBe('2025-12-08T17:00:00.000Z'); }); + it('chains messages in array order when the export carries no parent uuids at all', () => { + const converted = convertClaudeConversation( + conversation([ + { + uuid: 'm1', + sender: 'human', + created_at: '2025-12-08T17:00:05.000Z', + content: [{ type: 'text', text: 'First' }], + }, + { + uuid: 'm2', + sender: 'assistant', + created_at: '2025-12-08T17:00:02.000Z', + content: [{ type: 'text', text: 'Second' }], + }, + ]), + OPTIONS, + ); + + const first = byText(converted.messages, 'First'); + const second = byText(converted.messages, 'Second'); + expect(first.parentMessageId).toBe(Constants.NO_PARENT); + expect(second.parentMessageId).toBe(first.messageId); + expect(second.createdAt.getTime()).toBeGreaterThan(first.createdAt.getTime()); + }); + it('returns no messages for a conversation with an empty message list', () => { const converted = convertClaudeConversation({ uuid: 'conv-3', chat_messages: [] }, OPTIONS); expect(converted.messages).toHaveLength(0); diff --git a/packages/api/src/import/claude/convert.ts b/packages/api/src/import/claude/convert.ts index 36e0134ca1..4301f45bb7 100644 --- a/packages/api/src/import/claude/convert.ts +++ b/packages/api/src/import/claude/convert.ts @@ -116,13 +116,24 @@ export function convertClaudeConversation( unavailable += entry.converted.unavailable; } + let previousId = Constants.NO_PARENT; + for (const entry of order) { const { message, converted } = entry; const isCreatedByUser = message.sender === HUMAN_SENDER; + /** An export that carries no `parent_message_uuid` at all (the field is + * absent rather than null) has no tree to rebuild, so its messages chain in + * array order — the shape the pre-branching importer always produced. An + * explicit `null` is a real root and is left alone. */ + const parentMessageId = + message.parent_message_uuid === undefined + ? previousId + : findParent(message.parent_message_uuid, prepared, emitted); + const built: ConvertedMessage = { messageId: entry.messageId, - parentMessageId: findParent(message.parent_message_uuid, prepared, emitted), + parentMessageId, text: converted.text, sender: isCreatedByUser ? 'user' : ASSISTANT_SENDER, isCreatedByUser, @@ -138,6 +149,7 @@ export function convertClaudeConversation( built.attachments = converted.attachments; } + previousId = built.messageId; messages.push(built); }