feat: import shared convos

This commit is contained in:
Dustin Healy 2026-02-18 15:33:36 -08:00
parent 50a48efa43
commit 503b7f4d8d
6 changed files with 126 additions and 8 deletions

View file

@ -242,8 +242,11 @@ router.post(
async (req, res) => {
try {
/* TODO: optimize to return imported conversations and add manually */
await importConversations({ filepath: req.file.path, requestUserId: req.user.id });
res.status(201).json({ message: 'Conversation(s) imported successfully' });
const conversationIds = await importConversations({
filepath: req.file.path,
requestUserId: req.user.id,
});
res.status(201).json({ message: 'Conversation(s) imported successfully', conversationIds });
} catch (error) {
logger.error('Error processing file', error);
res.status(500).send('Error processing file');

View file

@ -22,8 +22,9 @@ const importConversations = async (job) => {
const fileData = await fs.readFile(filepath, 'utf8');
const jsonData = JSON.parse(fileData);
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId);
const conversationIds = await importer(jsonData, requestUserId);
logger.debug(`user: ${requestUserId} | Finished importing conversations`);
return conversationIds;
} catch (error) {
logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
throw error; // throw error all the way up so request does not return success

View file

@ -72,6 +72,7 @@ async function importChatBotUiConvo(
}
await importBatchBuilder.saveBatch();
logger.info(`user: ${requestUserId} | ChatbotUI conversation imported`);
return importBatchBuilder.conversations.map((c) => c.conversationId);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from ChatbotUI file`, error);
}
@ -177,6 +178,7 @@ async function importClaudeConvo(
await importBatchBuilder.saveBatch();
logger.info(`user: ${requestUserId} | Claude conversation imported`);
return importBatchBuilder.conversations.map((c) => c.conversationId);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from Claude file`, error);
}
@ -272,6 +274,7 @@ async function importLibreChatConvo(
importBatchBuilder.finishConversation(jsonData.title, firstMessageDate ?? new Date(), options);
await importBatchBuilder.saveBatch();
logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`);
return importBatchBuilder.conversations.map((c) => c.conversationId);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from LibreChat file`, error);
}
@ -297,6 +300,7 @@ async function importChatGptConvo(
processConversation(conv, importBatchBuilder, requestUserId);
}
await importBatchBuilder.saveBatch();
return importBatchBuilder.conversations.map((c) => c.conversationId);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from imported file`, error);
}

View file

@ -2,9 +2,10 @@ import { memo, useState, useCallback, useContext } from 'react';
import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { useParams } from 'react-router-dom';
import { buildTree } from 'librechat-data-provider';
import { CalendarDays, Settings } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { CalendarDays, Settings, ExternalLink } from 'lucide-react';
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
import { buildTree, request, setTokenHeader } from 'librechat-data-provider';
import {
Spinner,
Button,
@ -13,26 +14,48 @@ import {
OGDialogTitle,
useMediaQuery,
OGDialogHeader,
useToastContext,
OGDialogContent,
OGDialogTrigger,
} from '@librechat/client';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
import { useGetStartupConfig, useUploadConversationsMutation } from '~/data-provider';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { ShareContext } from '~/Providers';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import MessagesView from './MessagesView';
import Footer from '../Chat/Footer';
import { cn } from '~/utils';
import store from '~/store';
function useOptionalAuth() {
const [enabled, setEnabled] = useState(false);
const { data: isAuthenticated = false } = useQuery({
queryKey: ['optionalAuth'],
queryFn: async () => {
const res = await request.refreshToken();
if (res?.token) {
setTokenHeader(res.token);
return true;
}
return false;
},
enabled,
retry: false,
refetchOnWindowFocus: false,
});
return { isAuthenticated, checkAuth: () => setEnabled(true) };
}
function SharedView() {
const localize = useLocalize();
const { data: config } = useGetStartupConfig();
const { theme, setTheme } = useContext(ThemeContext);
const { shareId } = useParams();
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
const { isAuthenticated, checkAuth } = useOptionalAuth();
const dataTree = data && buildTree({ messages: data.messages });
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
@ -108,6 +131,9 @@ function SharedView() {
onThemeChange={handleThemeChange}
onLangChange={handleLangChange}
settingsLabel={localize('com_nav_settings')}
isAuthenticated={isAuthenticated}
onSettingsOpen={checkAuth}
sharedData={data}
/>
<ShareMessagesProvider messages={data.messages}>
<MessagesView messagesTree={messagesTree} conversationId="shared-conversation" />
@ -165,6 +191,9 @@ interface ShareHeaderProps {
theme: string;
langcode: string;
settingsLabel: string;
isAuthenticated: boolean;
onSettingsOpen: () => void;
sharedData: { conversationId: string; title: string; messages: unknown[] } | undefined;
onThemeChange: (value: string) => void;
onLangChange: (value: string) => void;
}
@ -175,12 +204,60 @@ function ShareHeader({
theme,
langcode,
settingsLabel,
isAuthenticated,
onSettingsOpen,
sharedData,
onThemeChange,
onLangChange,
}: ShareHeaderProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [settingsOpen, setSettingsOpen] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const isMobile = useMediaQuery('(max-width: 767px)');
const handleSettingsOpenChange = useCallback(
(open: boolean) => {
setSettingsOpen(open);
if (open) {
onSettingsOpen();
}
},
[onSettingsOpen],
);
const uploadConversation = useUploadConversationsMutation({
onSuccess: (data) => {
setIsImporting(false);
const id = data?.conversationIds?.[0];
window.location.href = id ? `/c/${id}` : '/c/new';
},
onError: () => {
showToast({
message: localize('com_ui_import_conversation_error'),
status: NotificationSeverity.ERROR,
});
setIsImporting(false);
},
onMutate: () => setIsImporting(true),
});
const handleImport = useCallback(() => {
if (!sharedData) {
return;
}
const json = JSON.stringify({
conversationId: 'import', // A new, valid UUID is automatically generated by the server on upload
title: sharedData.title,
messages: sharedData.messages,
});
const blob = new Blob([json], { type: 'application/json' });
const file = new File([blob], 'shared-conversation.json', { type: 'application/json' });
const formData = new FormData();
formData.append('file', file, file.name);
uploadConversation.mutate(formData);
}, [sharedData, uploadConversation]);
const handleDialogOutside = useCallback((event: Event) => {
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dialog-ignore="true"]')) {
@ -202,7 +279,7 @@ function ShareHeader({
)}
</div>
<OGDialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<OGDialog open={settingsOpen} onOpenChange={handleSettingsOpenChange}>
<OGDialogTrigger asChild>
<Button
size={isMobile ? 'icon' : 'default'}
@ -237,6 +314,34 @@ function ShareHeader({
<div className="relative focus-within:z-[100]">
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
</div>
{isAuthenticated && (
<>
<div className="bg-border-medium/60 h-px w-full" />
<div className="flex items-center justify-between">
<span className="text-text-primary">
{localize('com_ui_import_conversation')}
</span>
<Button
variant="outline"
disabled={isImporting}
onClick={handleImport}
aria-label={localize('com_ui_import')}
>
{isImporting ? (
<>
<Spinner className="mr-1 w-4" />
<span>{localize('com_ui_importing')}</span>
</>
) : (
<>
<span>{localize('com_ui_import')}</span>
<ExternalLink className="ml-1 h-4 w-4" aria-hidden="true" />
</>
)}
</Button>
</div>
</>
)}
</div>
</OGDialogContent>
</OGDialog>

View file

@ -1059,6 +1059,7 @@
"com_ui_image_edited": "Image edited",
"com_ui_image_gen": "Image Gen",
"com_ui_import": "Import",
"com_ui_import_conversation": "Import Conversation",
"com_ui_import_conversation_error": "There was an error importing your conversations",
"com_ui_import_conversation_file_type_error": "Unsupported import type",
"com_ui_import_conversation_info": "Import conversations from a JSON file",

View file

@ -520,6 +520,10 @@ export type TImportResponse = {
* The message associated with the response.
*/
message: string;
/**
* The IDs of the imported conversations.
*/
conversationIds?: string[];
};
/** Prompts */