Skip to content

Commit

Permalink
Add exporting all conversations feature
Browse files Browse the repository at this point in the history
  • Loading branch information
jakubmieszczak committed May 20, 2024
1 parent 1a45212 commit 10b58d1
Show file tree
Hide file tree
Showing 12 changed files with 286 additions and 9 deletions.
9 changes: 9 additions & 0 deletions api/models/Conversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ module.exports = {
return { message: 'Error getting conversations' };
}
},
getAllConvos: async (user) => {
try {
const convos = await Conversation.find({ user }).sort({ updatedAt: -1 }).lean();
return { conversations: convos };
} catch (error) {
logger.error('[getAllConvos] Error getting conversations', error);
return { message: 'Error getting conversations' };
}
},
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => {
try {
if (!convoIds || convoIds.length === 0) {
Expand Down
47 changes: 44 additions & 3 deletions api/server/routes/convos.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ const express = require('express');
const { CacheKeys } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const { IMPORT_CONVERSATION_JOB_NAME } = require('~/server/utils/import/jobDefinition');
const {
IMPORT_CONVERSATION_JOB_NAME,
EXPORT_CONVERSATION_JOB_NAME,
} = require('~/server/utils/import/jobDefinition');
const { storage, importFileFilter } = require('~/server/routes/files/multer');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { forkConversation } = require('~/server/utils/import/fork');
Expand All @@ -12,6 +15,8 @@ const jobScheduler = require('~/server/utils/jobScheduler');
const getLogStores = require('~/cache/getLogStores');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
const os = require('os');
const path = require('path');

const router = express.Router();
router.use(requireJwtAuth);
Expand Down Expand Up @@ -168,9 +173,17 @@ router.post('/fork', async (req, res) => {
res.status(500).send('Error forking conversation');
}
});
router.post('/export', importIpLimiter, importUserLimiter, async (req, res) => {
try {
const job = await jobScheduler.now(EXPORT_CONVERSATION_JOB_NAME, '', req.user.id);
res.status(200).json({ message: 'Export started', jobId: job.id });
} catch (error) {
console.error('Error exporting conversations', error);
res.status(500).send('Error exporting conversations');
}
});

// Get the status of an import job for polling
router.get('/import/jobs/:jobId', async (req, res) => {
const jobStatusHandler = async (req, res) => {
try {
const { jobId } = req.params;
const { userId, ...jobStatus } = await jobScheduler.getJobStatus(jobId);
Expand All @@ -187,6 +200,34 @@ router.get('/import/jobs/:jobId', async (req, res) => {
logger.error('Error getting job details', error);
res.status(500).send('Error getting job details');
}
};

// Get the status of an import job for polling
router.get('/import/jobs/:jobId', jobStatusHandler);

// Get the status of an export job for polling
router.get('/export/jobs/:jobId', jobStatusHandler);

router.get('/export/jobs/:jobId/conversations.json', async (req, res) => {
logger.info('Downloading JSON file');
try {
//put this in a function
const { jobId } = req.params;
const tempDir = os.tmpdir();
const filePath = path.join(tempDir, `export-${jobId}`);

res.setHeader('Content-Type', 'application/json');

res.sendFile(filePath, (err) => {
if (err) {
console.error(err);
res.status(500).send('An error occurred');
}
});
} catch (error) {
console.error('Error downloading JSON file', error);
res.status(500).send('Error downloading JSON file');
}
});

module.exports = router;
69 changes: 67 additions & 2 deletions api/server/utils/import/jobDefinition.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ const jobScheduler = require('~/server/utils/jobScheduler');
const { getImporter } = require('./importers');
const { indexSync } = require('~/lib/db');
const { logger } = require('~/config');
const { getAllConvos } = require('~/models/Conversation');
const { getMessages } = require('~/models');
const os = require('os');
const path = require('path');

const IMPORT_CONVERSATION_JOB_NAME = 'import conversation';
const EXPORT_CONVERSATION_JOB_NAME = 'export conversation';

/**
* Job definition for importing a conversation.
Expand Down Expand Up @@ -35,7 +40,67 @@ const importConversationJob = async (job, done) => {
}
};

// Call the jobScheduler.define function at startup
/**
* Create a temporary file and delete it after a delay.
* @param {object} content - The content to write to the file.
* @param {number} delay - The delay in milliseconds to delete the file.
* @param {string} job - The job object.
* @returns {Promise<string>} The temporary file path.
*/
async function createAndDeleteTempFile(content, delay, job) {
const { requestUserId } = job.attrs.data;
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, `export-${job.attrs._id}`);
try {
await fs.writeFile(tempFilePath, JSON.stringify(content));
logger.debug(`user: ${requestUserId} | Created temporary file at: ${tempFilePath}`);
setTimeout(async () => {
try {
await fs.unlink(tempFilePath);
logger.debug(
`user: ${requestUserId} | Automatically deleted temporary file at: ${tempFilePath}`,
);
} catch (error) {
logger.error(
`user: ${requestUserId} | Failed to automatically delete temporary file at: ${tempFilePath}`,
error,
);
}
}, delay);
return tempFilePath;
} catch (error) {
logger.error(
`user: ${requestUserId} | Error handling the temporary file: ${tempFilePath}`,
error,
);
}
}

/**
* Job definition for exporting all conversations.
* @param {import('agenda').Job} job - The job object.
* @param {Function} done - The done function.
*/
const exportConversationJob = async (job, done) => {
const { requestUserId } = job.attrs.data;
try {
const convos = await getAllConvos(requestUserId);

for (let i = 0; i < convos.conversations.length; i++) {
const conversationId = convos.conversations[i].conversationId;
convos.conversations[i].messages = await getMessages({ conversationId });
}
// Temporary file will be deleted from server after 5 minutes
createAndDeleteTempFile(convos, 5 * 60 * 1000, job);
done();
} catch (error) {
logger.error('Failed to export conversation: ', error);
done(error);
}
};

// Call the jobScheduler.define functions at startup
jobScheduler.define(IMPORT_CONVERSATION_JOB_NAME, importConversationJob);
jobScheduler.define(EXPORT_CONVERSATION_JOB_NAME, exportConversationJob);

module.exports = { IMPORT_CONVERSATION_JOB_NAME };
module.exports = { IMPORT_CONVERSATION_JOB_NAME, EXPORT_CONVERSATION_JOB_NAME };
5 changes: 4 additions & 1 deletion client/src/components/Nav/SettingsTabs/Data/Data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ImportConversations from './ImportConversations';
import { ClearChatsButton } from './ClearChats';
import DangerButton from '../DangerButton';
import SharedLinks from './SharedLinks';
import ExportConversations from './ExportConversations';

export const RevokeKeysButton = ({
showText = true,
Expand Down Expand Up @@ -108,13 +109,15 @@ function Data() {
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ImportConversations />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
<ExportConversations />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<SharedLinks />
</div>
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<RevokeKeysButton all={true} />
</div>

<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ClearChatsButton
confirmClear={confirmClearConvos}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { ArrowRightFromLine } from 'lucide-react';
import { useExportConversationsMutation } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { useToastContext } from '~/Providers';

function ExportConversations() {
const localize = useLocalize();
const { showToast } = useToastContext();
const [errors, setErrors] = useState<string[]>([]);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);

const exportFile = useExportConversationsMutation({
onSuccess: (data) => {
console.log('Exporting started', data);

showToast({ message: localize('com_ui_export_conversation_success') });

// Directly initiate download here if `data` contains downloadable content or URL
downloadConversationsJsonFile(data);
},
onError: (error) => {
console.error('Error: ', error);
setError(
(error as { response: { data: { message?: string } } }).response.data.message ??
'An error occurred while exporting the file.',
);
showToast({ message: localize('com_ui_export_conversation_error'), status: 'error' });
},
});

const startExport = () => {
const formdata = new FormData();
exportFile.mutate(formdata);
};

const downloadConversationsJsonFile = (data) => {
// Assuming `data` is the downloadable content; adjust as necessary for your use case
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);

const link = document.createElement('a');
link.href = url;
link.download = 'conversations.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

return (
<>
<div className="flex items-center justify-between">
<span>{localize('com_nav_export_all_conversations')}</span>
<button
onClick={startExport}
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
>
<ArrowRightFromLine className="mr-1 flex w-[22px] items-center stroke-1" />
<span>{localize('com_endpoint_export')}</span>
</button>
</div>
</>
);
}

export default ExportConversations;
61 changes: 61 additions & 0 deletions client/src/data-provider/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,67 @@ export const useUploadConversationsMutation = (
});
};

export const useExportConversationsMutation = (
_options?: t.MutationOptions<t.TImportJobStatus, FormData>,
) => {
const queryClient = useQueryClient();
const { onSuccess, onError } = _options || {};

const checkJobStatus = async (jobId) => {
try {
const response = await dataService.queryExportAllConversationJobStatus(jobId);
return response;
} catch (error) {
throw new Error('Failed to check job status');
}
};

const pollJobStatus = (jobId, onSuccess, onError) => {
const intervalId = setInterval(async () => {
try {
const statusResponse = await checkJobStatus(jobId);
console.log('Polling job status:', statusResponse);
if (statusResponse.status === 'completed' || statusResponse.status === 'failed') {
clearInterval(intervalId);
if (statusResponse.status === 'completed') {
onSuccess && onSuccess();
} else {
onError &&
onError(new Error(statusResponse.failReason || 'Failed to export conversations'));
}
}
} catch (error) {
clearInterval(intervalId);
onError && onError(error);
}
}, 500); // Poll every 0,5 seconds. Adjust time as necessary.
};

return useMutation<t.ImportStartResponse, unknown, FormData>({
mutationFn: (formData: FormData) => dataService.exportAllConversationsToJson(formData),
onSuccess: (data) => {
queryClient.invalidateQueries(QueryKeys.allConversations);
const jobId = data.jobId;
if (jobId) {
console.log('Job ID:', jobId);
pollJobStatus(
jobId,
async () => {
queryClient.invalidateQueries(QueryKeys.allConversations);
onSuccess?.(await dataService.exportConversationsFile(jobId));
},
(error) => {
onError?.(error, { jobId }, { context: 'ExportJobFailed' });
},
);
}
},
onError: (err, variables, context) => {
onError?.(err, variables, context);
},
});
};

export const useUploadFileMutation = (
_options?: t.UploadMutationOptions,
): UseMutationResult<
Expand Down
3 changes: 3 additions & 0 deletions client/src/localization/languages/Eng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export default {
com_ui_import_conversation_success: 'Conversations imported successfully',
com_ui_import_conversation_error: 'There was an error importing your conversations',
com_ui_import_conversation_file_type_error: 'Unsupported import type',
com_ui_export_conversation_error: 'There was an error exporting your conversations',
com_ui_export_conversation_success: 'Successfully exported conversations',
com_ui_confirm_action: 'Confirm Action',
com_ui_chats: 'chats',
com_ui_avatar: 'Avatar',
Expand Down Expand Up @@ -512,6 +514,7 @@ export default {
com_nav_shared_links_name: 'Name',
com_nav_shared_links_date_shared: 'Date shared',
com_nav_source_chat: 'View source chat',
com_nav_export_all_conversations: 'Export all conversations to a JSON file',
com_nav_my_files: 'My Files',
com_nav_theme: 'Theme',
com_nav_theme_system: 'System',
Expand Down
9 changes: 6 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,8 @@
"admin/",
"packages/"
]
},
"dependencies": {
"ollama": "^0.5.1"
}
}
8 changes: 8 additions & 0 deletions packages/data-provider/src/api-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export const forkConversation = () => `${conversationsRoot}/fork`;
export const importConversationJobStatus = (jobId: string) =>
`${conversationsRoot}/import/jobs/${jobId}`;

export const exportAllConversations = () => `${conversationsRoot}/export`;

export const exportAllConversationsJobStatus = (jobId: string) =>
`${conversationsRoot}/export/jobs/${jobId}`;

export const downloadExportedConversations = (jobId: string) =>
`${conversationsRoot}/export/jobs/${jobId}/conversations.json`;

export const search = (q: string, pageNumber: string) =>
`/api/search?q=${q}&pageNumber=${pageNumber}`;

Expand Down
Loading

0 comments on commit 10b58d1

Please sign in to comment.