mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-03-09 13:41:00 +00:00
feat: restoring project from snapshot on reload (#444)
* feat:(project-snapshot) restoring project from snapshot on reload * minor bugfix * updated message * added snapshot reload with auto run dev commands * added message context * snapshot updated
This commit is contained in:
parent
73a0f3ae24
commit
1f940391b1
7
app/lib/persistence/types.ts
Normal file
7
app/lib/persistence/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
|
||||
export interface Snapshot {
|
||||
chatIndex: string;
|
||||
files: FileMap;
|
||||
summary?: string;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { useLoaderData, useNavigate, useSearchParams } from '@remix-run/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Message } from 'ai';
|
||||
import { generateId, type JSONValue, type Message } from 'ai';
|
||||
import { toast } from 'react-toastify';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { logStore } from '~/lib/stores/logs'; // Import logStore
|
||||
@ -15,6 +15,11 @@ import {
|
||||
createChatFromMessages,
|
||||
type IChatMetadata,
|
||||
} from './db';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import type { Snapshot } from './types';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { createCommandsMessage, detectProjectCommands } from '~/utils/projectCommands';
|
||||
import type { ContextAnnotation } from '~/types/context';
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
id: string;
|
||||
@ -37,6 +42,7 @@ export function useChatHistory() {
|
||||
const { id: mixedId } = useLoaderData<{ id?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [archivedMessages, setArchivedMessages] = useState<Message[]>([]);
|
||||
const [initialMessages, setInitialMessages] = useState<Message[]>([]);
|
||||
const [ready, setReady] = useState<boolean>(false);
|
||||
const [urlId, setUrlId] = useState<string | undefined>();
|
||||
@ -56,14 +62,128 @@ export function useChatHistory() {
|
||||
|
||||
if (mixedId) {
|
||||
getMessages(db, mixedId)
|
||||
.then((storedMessages) => {
|
||||
.then(async (storedMessages) => {
|
||||
if (storedMessages && storedMessages.messages.length > 0) {
|
||||
const snapshotStr = localStorage.getItem(`snapshot:${mixedId}`);
|
||||
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
|
||||
const summary = snapshot.summary;
|
||||
|
||||
const rewindId = searchParams.get('rewindTo');
|
||||
const filteredMessages = rewindId
|
||||
? storedMessages.messages.slice(0, storedMessages.messages.findIndex((m) => m.id === rewindId) + 1)
|
||||
: storedMessages.messages;
|
||||
let startingIdx = 0;
|
||||
const endingIdx = rewindId
|
||||
? storedMessages.messages.findIndex((m) => m.id === rewindId) + 1
|
||||
: storedMessages.messages.length;
|
||||
const snapshotIndex = storedMessages.messages.findIndex((m) => m.id === snapshot.chatIndex);
|
||||
|
||||
if (snapshotIndex >= 0 && snapshotIndex < endingIdx) {
|
||||
startingIdx = snapshotIndex;
|
||||
}
|
||||
|
||||
if (snapshotIndex > 0 && storedMessages.messages[snapshotIndex].id == rewindId) {
|
||||
startingIdx = 0;
|
||||
}
|
||||
|
||||
let filteredMessages = storedMessages.messages.slice(startingIdx + 1, endingIdx);
|
||||
let archivedMessages: Message[] = [];
|
||||
|
||||
if (startingIdx > 0) {
|
||||
archivedMessages = storedMessages.messages.slice(0, startingIdx + 1);
|
||||
}
|
||||
|
||||
setArchivedMessages(archivedMessages);
|
||||
|
||||
if (startingIdx > 0) {
|
||||
const files = Object.entries(snapshot?.files || {})
|
||||
.map(([key, value]) => {
|
||||
if (value?.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
content: value.content,
|
||||
path: key,
|
||||
};
|
||||
})
|
||||
.filter((x) => !!x);
|
||||
const projectCommands = await detectProjectCommands(files);
|
||||
const commands = createCommandsMessage(projectCommands);
|
||||
|
||||
filteredMessages = [
|
||||
{
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: `Restore project from snapshot
|
||||
`,
|
||||
annotations: ['no-store', 'hidden'],
|
||||
},
|
||||
{
|
||||
id: storedMessages.messages[snapshotIndex].id,
|
||||
role: 'assistant',
|
||||
content: ` 📦 Chat Restored from snapshot, You can revert this message to load the full chat history
|
||||
<boltArtifact id="imported-files" title="Project Files Snapshot" type="bundled">
|
||||
${Object.entries(snapshot?.files || {})
|
||||
.filter((x) => !x[0].endsWith('lock.json'))
|
||||
.map(([key, value]) => {
|
||||
if (value?.type === 'file') {
|
||||
return `
|
||||
<boltAction type="file" filePath="${key}">
|
||||
${value.content}
|
||||
</boltAction>
|
||||
`;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
})
|
||||
.join('\n')}
|
||||
</boltArtifact>
|
||||
`,
|
||||
annotations: [
|
||||
'no-store',
|
||||
...(summary
|
||||
? [
|
||||
{
|
||||
chatId: storedMessages.messages[snapshotIndex].id,
|
||||
type: 'chatSummary',
|
||||
summary,
|
||||
} satisfies ContextAnnotation,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
...(commands !== null
|
||||
? [
|
||||
{
|
||||
id: `${storedMessages.messages[snapshotIndex].id}-2`,
|
||||
role: 'user' as const,
|
||||
content: `setup project`,
|
||||
annotations: ['no-store', 'hidden'],
|
||||
},
|
||||
{
|
||||
...commands,
|
||||
id: `${storedMessages.messages[snapshotIndex].id}-3`,
|
||||
annotations: [
|
||||
'no-store',
|
||||
...(commands.annotations || []),
|
||||
...(summary
|
||||
? [
|
||||
{
|
||||
chatId: `${storedMessages.messages[snapshotIndex].id}-3`,
|
||||
type: 'chatSummary',
|
||||
summary,
|
||||
} satisfies ContextAnnotation,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...filteredMessages,
|
||||
];
|
||||
restoreSnapshot(mixedId);
|
||||
}
|
||||
|
||||
setInitialMessages(filteredMessages);
|
||||
|
||||
setUrlId(storedMessages.urlId);
|
||||
description.set(storedMessages.description);
|
||||
chatId.set(storedMessages.id);
|
||||
@ -75,10 +195,64 @@ export function useChatHistory() {
|
||||
setReady(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
logStore.logError('Failed to load chat messages', error);
|
||||
toast.error(error.message);
|
||||
});
|
||||
}
|
||||
}, [mixedId]);
|
||||
|
||||
const takeSnapshot = useCallback(
|
||||
async (chatIdx: string, files: FileMap, _chatId?: string | undefined, chatSummary?: string) => {
|
||||
const id = _chatId || chatId;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
chatIndex: chatIdx,
|
||||
files,
|
||||
summary: chatSummary,
|
||||
};
|
||||
localStorage.setItem(`snapshot:${id}`, JSON.stringify(snapshot));
|
||||
},
|
||||
[chatId],
|
||||
);
|
||||
|
||||
const restoreSnapshot = useCallback(async (id: string) => {
|
||||
const snapshotStr = localStorage.getItem(`snapshot:${id}`);
|
||||
const container = await webcontainer;
|
||||
|
||||
// if (snapshotStr)setSnapshot(JSON.parse(snapshotStr));
|
||||
const snapshot: Snapshot = snapshotStr ? JSON.parse(snapshotStr) : { chatIndex: 0, files: {} };
|
||||
|
||||
if (!snapshot?.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(snapshot.files).forEach(async ([key, value]) => {
|
||||
if (key.startsWith(container.workdir)) {
|
||||
key = key.replace(container.workdir, '');
|
||||
}
|
||||
|
||||
if (value?.type === 'folder') {
|
||||
await container.fs.mkdir(key, { recursive: true });
|
||||
}
|
||||
});
|
||||
Object.entries(snapshot.files).forEach(async ([key, value]) => {
|
||||
if (value?.type === 'file') {
|
||||
if (key.startsWith(container.workdir)) {
|
||||
key = key.replace(container.workdir, '');
|
||||
}
|
||||
|
||||
await container.fs.writeFile(key, value.content, { encoding: value.isBinary ? undefined : 'utf8' });
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
||||
// workbenchStore.files.setKey(snapshot?.files)
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@ -105,14 +279,34 @@ export function useChatHistory() {
|
||||
}
|
||||
|
||||
const { firstArtifact } = workbenchStore;
|
||||
messages = messages.filter((m) => !m.annotations?.includes('no-store'));
|
||||
|
||||
let _urlId = urlId;
|
||||
|
||||
if (!urlId && firstArtifact?.id) {
|
||||
const urlId = await getUrlId(db, firstArtifact.id);
|
||||
|
||||
_urlId = urlId;
|
||||
navigateChat(urlId);
|
||||
setUrlId(urlId);
|
||||
}
|
||||
|
||||
let chatSummary: string | undefined = undefined;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (lastMessage.role === 'assistant') {
|
||||
const annotations = lastMessage.annotations as JSONValue[];
|
||||
const filteredAnnotations = (annotations?.filter(
|
||||
(annotation: JSONValue) =>
|
||||
annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
||||
) || []) as { type: string; value: any } & { [key: string]: any }[];
|
||||
|
||||
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
|
||||
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
|
||||
}
|
||||
}
|
||||
|
||||
takeSnapshot(messages[messages.length - 1].id, workbenchStore.files.get(), _urlId, chatSummary);
|
||||
|
||||
if (!description.get() && firstArtifact?.title) {
|
||||
description.set(firstArtifact?.title);
|
||||
}
|
||||
@ -127,7 +321,15 @@ export function useChatHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
await setMessages(db, chatId.get() as string, messages, urlId, description.get(), undefined, chatMetadata.get());
|
||||
await setMessages(
|
||||
db,
|
||||
chatId.get() as string,
|
||||
[...archivedMessages, ...messages],
|
||||
urlId,
|
||||
description.get(),
|
||||
undefined,
|
||||
chatMetadata.get(),
|
||||
);
|
||||
},
|
||||
duplicateCurrentChat: async (listItemId: string) => {
|
||||
if (!db || (!mixedId && !listItemId)) {
|
||||
|
Loading…
Reference in New Issue
Block a user