Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add chat history backup and restore functionality #871

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3fa4b87
feat: Add chat history backup and restore functionality
sidbetatester Nov 14, 2024
74357d0
feat: Add chat history backup and restore functionality with Chrome e…
sidbetatester Nov 14, 2024
a600fa4
Merge branch 'coleam00:main' into main
sidbetatester Nov 14, 2024
f51a31b
Merge branch 'coleam00:main' into main
sidbetatester Nov 16, 2024
d4f09e4
Merge branch 'coleam00:main' into main
sidbetatester Nov 17, 2024
70038bf
Merge branch 'coleam00:main' into main
sidbetatester Nov 19, 2024
ad18932
Merge branch 'coleam00:main' into main
sidbetatester Nov 19, 2024
a493fd2
Update README.md
sidbetatester Nov 19, 2024
586b567
Merge: Preserve chat history backup/restore and duplicate chat features
sidbetatester Nov 21, 2024
79bff63
Merge branch 'coleam00:main' into main
sidbetatester Nov 21, 2024
d3c5eff
Merge branch 'coleam00:main' into main
sidbetatester Nov 22, 2024
227f967
Merge branch 'coleam00:main' into main
sidbetatester Nov 23, 2024
45690a5
Merge branch 'main' into main
sidbetatester Dec 15, 2024
bee45d5
chore: update commit hash to 45690a59b84a1849bbcd4b08d6d304e1d6a26895
github-actions[bot] Dec 15, 2024
ce23ec2
feat: add multi-format support to chat import
sidbetatester Dec 22, 2024
139a26d
docs: add chat history backup to completed features
sidbetatester Dec 22, 2024
e81e34e
chore: update commit hash to 139a26dbf85ee098b0f6642083a83c37d8e499fe
github-actions[bot] Dec 22, 2024
b0e5577
Delete app/commit.json
sidbetatester Dec 22, 2024
9b79f7e
Merge branch 'stackblitz-labs:main' into main
sidbetatester Dec 23, 2024
b2afb79
Merge branch 'stackblitz-labs:main' into main
sidbetatester Dec 25, 2024
8a08aa5
Merge branch 'stackblitz-labs:main' into main
sidbetatester Dec 28, 2024
b2a205b
Merge branch 'stackblitz-labs:main' into main
sidbetatester Dec 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ bolt.diy was originally started by [Cole Medin](https://www.youtube.com/@ColeMed
- ✅ Bolt terminal to see the output of LLM run commands (@thecodacus)
- ✅ Streaming of code output (@thecodacus)
- ✅ Ability to revert code to earlier version (@wonderwhy-er)
- ✅ Chat history backup and restore functionality (@sidbetatester)
- ✅ Cohere Integration (@hasanraiyan)
- ✅ Dynamic model max token length (@hasanraiyan)
- ✅ Better prompt enhancing (@SujalXplores)
Expand Down
Empty file.
37 changes: 31 additions & 6 deletions app/components/chat/chatExportAndImport/ImportButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@ import type { Message } from 'ai';
import { toast } from 'react-toastify';
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';

const processChatData = (data: any): { description: string; messages: Message[] }[] => {
// Handle Bolt standard format
if (data.messages && Array.isArray(data.messages)) {
return [{ description: data.description || 'Imported Chat', messages: data.messages }];
}

// Handle Chrome extension format
if (data.boltHistory?.chats) {
return Object.values(data.boltHistory.chats).map((chat: any) => ({
description: chat.description || 'Imported Chat',
messages: chat.messages
}));
}

// Handle history array format
if (data.history && Array.isArray(data.history)) {
return data.history.map((chat: any) => ({
description: chat.description || 'Imported Chat',
messages: chat.messages
}));
}

throw new Error('Unsupported chat format');
};

export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
return (
<div className="flex flex-col items-center justify-center w-auto">
Expand All @@ -21,13 +46,13 @@ export function ImportButtons(importChat: ((description: string, messages: Messa
try {
const content = e.target?.result as string;
const data = JSON.parse(content);

if (!Array.isArray(data.messages)) {
toast.error('Invalid chat file format');
const chats = processChatData(data);

for (const chat of chats) {
await importChat(chat.description, chat.messages);
}

await importChat(data.description, data.messages);
toast.success('Chat imported successfully');

toast.success(`Successfully imported ${chats.length} chat${chats.length > 1 ? 's' : ''}`);
} catch (error: unknown) {
if (error instanceof Error) {
toast.error('Failed to parse chat file: ' + error.message);
Expand Down
216 changes: 183 additions & 33 deletions app/components/sidebar/Menu.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
import { SettingsWindow } from '~/components/settings/SettingsWindow';
import { SettingsButton } from '~/components/ui/SettingsButton';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, useChatHistory } from '~/lib/persistence';
import { IconButton } from '~/components/ui/IconButton';
import { db, deleteById, getAll, chatId, type ChatHistoryItem, setMessages, useChatHistory } from '~/lib/persistence';
import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger';
import { HistoryItem } from './HistoryItem';
Expand Down Expand Up @@ -35,32 +34,12 @@ const menuVariants = {

type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;

function CurrentDateTime() {
const [dateTime, setDateTime] = useState(new Date());

useEffect(() => {
const timer = setInterval(() => {
setDateTime(new Date());
}, 60000); // Update every minute

return () => clearInterval(timer);
}, []);

return (
<div className="flex items-center gap-2 px-4 py-3 font-bold text-gray-700 dark:text-gray-300 border-b border-bolt-elements-borderColor">
<div className="h-4 w-4 i-ph:clock-thin" />
{dateTime.toLocaleDateString()} {dateTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
);
}

export const Menu = () => {
export function Menu() {
const { duplicateCurrentChat, exportChat } = useChatHistory();
const menuRef = useRef<HTMLDivElement>(null);
const [list, setList] = useState<ChatHistoryItem[]>([]);
const [open, setOpen] = useState(false);
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);

const { filteredItems: filteredList, handleSearchChange } = useSearchFilter({
items: list,
Expand Down Expand Up @@ -127,6 +106,158 @@ export const Menu = () => {
};
}, []);

const exportChatHistory = useCallback(async () => {
try {
if (!db) {
throw new Error('Database not initialized');
}

const history = await getAll(db);
const backupData = {
version: '1.0',
timestamp: new Date().toISOString(),
history,
};

const blob = new Blob([JSON.stringify(backupData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bolt-chat-history-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Chat history exported successfully');
} catch (error) {
logger.error('Failed to export chat history:', error);
toast.error('Failed to export chat history');
}
}, []);

const importChatHistory = useCallback(async () => {
try {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';

input.onchange = async (e) => {
try {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) {
throw new Error('No file selected');
}

const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
logger.info('Parsing backup file content:', content.slice(0, 200) + '...');
const backupData = JSON.parse(content);

// Basic validation with detailed logging
logger.info('Validating backup data:', {
hasVersion: !!backupData.version,
version: backupData.version,
hasHistory: !!backupData.history,
historyIsArray: Array.isArray(backupData.history),
historyLength: backupData.history?.length,
rawKeys: Object.keys(backupData)
});

if (!db) {
throw new Error('Database not initialized');
}

let chatHistory;

// Handle different backup formats
if (backupData.version && backupData.history) {
// Our standard format
chatHistory = backupData.history;
} else if (backupData.boltHistory) {
// Chrome extension IndexedDB backup format
chatHistory = Object.values(backupData.boltHistory.chats || {});
logger.info('Detected Chrome extension backup format', {
itemCount: chatHistory.length,
sampleItem: chatHistory[0]
});
} else if (Array.isArray(backupData)) {
// Direct array format
chatHistory = backupData;
} else {
// Try to find any object with chat-like properties
const possibleChats = Object.values(backupData).find(value =>
Array.isArray(value) ||
(typeof value === 'object' && value !== null && 'messages' in value)
);

if (possibleChats) {
chatHistory = Array.isArray(possibleChats) ? possibleChats : [possibleChats];
logger.info('Found possible chat data in alternate format', {
itemCount: chatHistory.length,
sampleItem: chatHistory[0]
});
} else {
throw new Error('Unrecognized backup file format');
}
}

// Validate and normalize chat items
const normalizedHistory = chatHistory.map(item => {
if (!item.id || !Array.isArray(item.messages)) {
throw new Error('Invalid chat item format');
}
return {
id: item.id,
messages: item.messages,
urlId: item.urlId || item.id,
description: item.description || `Imported chat ${item.id}`
};
});

// Store each chat history item
logger.info('Starting import of chat history items');
for (const item of normalizedHistory) {
logger.info('Importing chat item:', { id: item.id, description: item.description });
await setMessages(db, item.id, item.messages, item.urlId, item.description);
}

toast.success(`Successfully imported ${normalizedHistory.length} chats`);
// Reload the page to show imported chats
window.location.reload();
} catch (error) {
logger.error('Failed to process backup file:', error);
// More detailed error message
if (error instanceof Error) {
toast.error(`Failed to process backup file: ${error.message}`);
} else {
toast.error('Failed to process backup file');
}
}
};
reader.readAsText(file);
} catch (error) {
logger.error('Failed to read backup file:', error);
if (error instanceof Error) {
toast.error(`Failed to read backup file: ${error.message}`);
} else {
toast.error('Failed to read backup file');
}
}
};

input.click();
} catch (error) {
logger.error('Failed to import chat history:', error);
if (error instanceof Error) {
toast.error(`Failed to import chat history: ${error.message}`);
} else {
toast.error('Failed to import chat history');
}
}
}, []);

const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
event.preventDefault();
setDialogContent({ type: 'delete', item });
Expand All @@ -145,17 +276,18 @@ export const Menu = () => {
variants={menuVariants}
className="flex selection-accent flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
>
<div className="h-[60px]" /> {/* Spacer for top margin */}
<CurrentDateTime />
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
<div className="p-4 select-none">
<a
href="/"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme mb-4"
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
>
<span className="inline-block i-bolt:chat scale-110" />
Start new chat
</a>
</div>
<div className="pl-4 pr-4 my-2">
<div className="relative w-full">
<input
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
Expand All @@ -166,7 +298,27 @@ export const Menu = () => {
/>
</div>
</div>
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
<div className="flex items-center justify-between pl-6 pr-5 my-2">
<div className="text-bolt-elements-textPrimary font-medium">Your Chats</div>
<div className="flex gap-2">
<IconButton
title="Import Chat History"
onClick={importChatHistory}
icon="i-ph-upload-simple-bold"
className="text-bolt-elements-textPrimary hover:text-bolt-elements-textTertiary transition-theme"
size="xxl"
iconClassName="scale-125"
/>
<IconButton
title="Export Chat History"
onClick={exportChatHistory}
icon="i-ph-download-simple-bold"
className="text-bolt-elements-textPrimary hover:text-bolt-elements-textTertiary transition-theme"
size="xxl"
iconClassName="scale-125"
/>
</div>
</div>
<div className="flex-1 overflow-auto pl-4 pr-5 pb-5">
{filteredList.length === 0 && (
<div className="pl-2 text-bolt-elements-textTertiary">
Expand Down Expand Up @@ -221,12 +373,10 @@ export const Menu = () => {
</Dialog>
</DialogRoot>
</div>
<div className="flex items-center justify-between border-t border-bolt-elements-borderColor p-4">
<SettingsButton onClick={() => setIsSettingsOpen(true)} />
<ThemeSwitch />
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
<ThemeSwitch className="ml-auto" />
</div>
</div>
<SettingsWindow open={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
</motion.div>
);
};
}
Empty file added app/utils/backup.ts
Empty file.
Loading