From f19f7a4aa1395037e775a12be4fb4a2a7a0f43ea Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Mon, 28 Oct 2024 20:48:00 -0500 Subject: [PATCH 01/12] Add save/revert functionality and fix file modification tracking --- app/components/editor/VersionHistory.tsx | 69 ++++++++++ app/components/workbench/EditorPanel.tsx | 36 ++--- app/components/workbench/Workbench.client.tsx | 18 +++ app/lib/runtime/action-runner.ts | 27 ++-- app/lib/stores/editor.ts | 29 +++++ app/lib/stores/files.ts | 91 +++++++------ app/lib/stores/version-history.ts | 123 ++++++++++++++++++ app/lib/stores/workbench.ts | 64 +++++---- 8 files changed, 367 insertions(+), 90 deletions(-) create mode 100644 app/components/editor/VersionHistory.tsx create mode 100644 app/lib/stores/version-history.ts diff --git a/app/components/editor/VersionHistory.tsx b/app/components/editor/VersionHistory.tsx new file mode 100644 index 000000000..e555ee077 --- /dev/null +++ b/app/components/editor/VersionHistory.tsx @@ -0,0 +1,69 @@ +import { useStore } from '@nanostores/react'; +import { useState } from 'react'; +import { versionHistoryStore } from '~/lib/stores/version-history'; + +interface VersionHistoryProps { + filePath: string; +} + +export function VersionHistory({ filePath }: VersionHistoryProps) { + const [isReverting, setIsReverting] = useState(false); + const versions = versionHistoryStore.getVersions(filePath); + const currentVersion = versionHistoryStore.getCurrentVersion(filePath); + + if (!versions.length) { + return null; + } + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleString(); + }; + + const handleRevert = async (versionIndex: number) => { + try { + setIsReverting(true); + await versionHistoryStore.revertToVersion(filePath, versionIndex); + } catch (error) { + console.error('Failed to revert file:', error); + } finally { + setIsReverting(false); + } + }; + + return ( +
+

Version History

+
+ {versions.map((version, index) => ( +
+
+
+ Version {versions.length - index} + + {formatDate(version.timestamp)} + +
+

{version.description}

+
+ {currentVersion && currentVersion.timestamp !== version.timestamp && ( + + )} +
+ ))} +
+
+ ); +} diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx index d1a265a66..97a8bd282 100644 --- a/app/components/workbench/EditorPanel.tsx +++ b/app/components/workbench/EditorPanel.tsx @@ -9,6 +9,7 @@ import { type OnSaveCallback as OnEditorSave, type OnScrollCallback as OnEditorScroll, } from '~/components/editor/codemirror/CodeMirrorEditor'; +import { VersionHistory } from '~/components/editor/VersionHistory'; import { IconButton } from '~/components/ui/IconButton'; import { PanelHeader } from '~/components/ui/PanelHeader'; import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; @@ -76,10 +77,6 @@ export const EditorPanel = memo( return editorDocument.filePath.split('/'); }, [editorDocument]); - const activeFileUnsaved = useMemo(() => { - return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath); - }, [editorDocument, unsavedFiles]); - useEffect(() => { const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => { terminalToggledByShortcut.current = true; @@ -149,7 +146,7 @@ export const EditorPanel = memo( {activeFileSegments?.length && (
- {activeFileUnsaved && ( + {editorDocument && (
@@ -164,17 +161,24 @@ export const EditorPanel = memo(
)} -
- +
+
+ +
+ {editorDocument && ( +
+ +
+ )}
diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 29c722c89..0a55c5907 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -79,6 +79,23 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => workbenchStore.setDocuments(files); }, [files]); + // Force workbench to show when a file is selected or modified + useEffect(() => { + if (selectedFile || (currentDocument && unsavedFiles.has(currentDocument.filePath))) { + workbenchStore.showWorkbench.set(true); + workbenchStore.currentView.set('code'); + } + }, [selectedFile, currentDocument, unsavedFiles]); + + // Show version history for files modified through chat + useEffect(() => { + const currentFile = currentDocument?.filePath; + if (currentFile && files[currentFile]) { + workbenchStore.setShowWorkbench(true); + workbenchStore.currentView.set('code'); + } + }, [files, currentDocument]); + const onEditorChange = useCallback((update) => { workbenchStore.setCurrentDocumentContent(update.content); }, []); @@ -230,6 +247,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => ) ); }); + interface ViewProps extends HTMLMotionProps<'div'> { children: JSX.Element; } diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index e2ea6a226..4260f93b1 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -5,6 +5,9 @@ import type { BoltAction } from '~/types/actions'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; +import { versionHistoryStore } from '../stores/version-history'; +import { workbenchStore } from '../stores/workbench'; +import type { FilesStore } from '../stores/files'; const logger = createScopedLogger('ActionRunner'); @@ -35,12 +38,14 @@ type ActionsMap = MapStore>; export class ActionRunner { #webcontainer: Promise; + #filesStore: FilesStore; #currentExecutionPromise: Promise = Promise.resolve(); actions: ActionsMap = map({}); - constructor(webcontainerPromise: Promise) { + constructor(webcontainerPromise: Promise, filesStore: FilesStore) { this.#webcontainer = webcontainerPromise; + this.#filesStore = filesStore; } addAction(data: ActionCallbackData) { @@ -50,7 +55,6 @@ export class ActionRunner { const action = actions[actionId]; if (action) { - // action already added return; } @@ -115,8 +119,6 @@ export class ActionRunner { this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' }); } catch (error) { this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }); - - // re-throw the error to be caught in the promise chain throw error; } } @@ -145,7 +147,6 @@ export class ActionRunner { ); const exitCode = await process.exit; - logger.debug(`Process terminated with code ${exitCode}`); } @@ -157,8 +158,6 @@ export class ActionRunner { const webcontainer = await this.#webcontainer; let folder = nodePath.dirname(action.filePath); - - // remove trailing slashes folder = folder.replace(/\/+$/g, ''); if (folder !== '.') { @@ -172,6 +171,19 @@ export class ActionRunner { try { await webcontainer.fs.writeFile(action.filePath, action.content); + + // Check if this is a modification of an existing file + if (this.#filesStore.isExistingFile(action.filePath)) { + // Only mark as modified if file existed before + const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get()); + newUnsavedFiles.add(action.filePath); + workbenchStore.unsavedFiles.set(newUnsavedFiles); + versionHistoryStore.addVersion(action.filePath, action.content, 'Modified through chat'); + } else { + // This is a new file + versionHistoryStore.addVersion(action.filePath, action.content, 'Initial version'); + } + logger.debug(`File written ${action.filePath}`); } catch (error) { logger.error('Failed to write file\n\n', error); @@ -180,7 +192,6 @@ export class ActionRunner { #updateAction(id: string, newState: ActionStateUpdate) { const actions = this.actions.get(); - this.actions.setKey(id, { ...actions[id], ...newState }); } } diff --git a/app/lib/stores/editor.ts b/app/lib/stores/editor.ts index ff3b3375f..9c33c475a 100644 --- a/app/lib/stores/editor.ts +++ b/app/lib/stores/editor.ts @@ -1,6 +1,8 @@ import { atom, computed, map, type MapStore, type WritableAtom } from 'nanostores'; import type { EditorDocument, ScrollPosition } from '~/components/editor/codemirror/CodeMirrorEditor'; import type { FileMap, FilesStore } from './files'; +import { versionHistoryStore } from './version-history'; +import { workbenchStore } from './workbench'; export type EditorDocuments = Record; @@ -8,6 +10,7 @@ type SelectedFile = WritableAtom; export class EditorStore { #filesStore: FilesStore; + #originalContent: Map = new Map(); selectedFile: SelectedFile = import.meta.hot?.data.selectedFile ?? atom(); documents: MapStore = import.meta.hot?.data.documents ?? map({}); @@ -42,12 +45,18 @@ export class EditorStore { const previousDocument = previousDocuments?.[filePath]; + // Store original content for reset functionality + if (!this.#originalContent.has(filePath)) { + this.#originalContent.set(filePath, dirent.content); + } + return [ filePath, { value: dirent.content, filePath, scroll: previousDocument?.scroll, + isBinary: dirent.isBinary, }, ] as [string, EditorDocument]; }) @@ -86,10 +95,30 @@ export class EditorStore { const contentChanged = currentContent !== newContent; if (contentChanged) { + // Add version when content changes + versionHistoryStore.addVersion(filePath, newContent, 'Modified in editor'); + + // Only mark as modified if it's an existing file + if (this.#filesStore.isExistingFile(filePath)) { + const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get()); + newUnsavedFiles.add(filePath); + workbenchStore.unsavedFiles.set(newUnsavedFiles); + } + this.documents.setKey(filePath, { ...documentState, value: newContent, }); } } + + resetFile(filePath: string) { + const originalContent = this.#originalContent.get(filePath); + if (originalContent) { + this.updateFile(filePath, originalContent); + const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get()); + newUnsavedFiles.delete(filePath); + workbenchStore.unsavedFiles.set(newUnsavedFiles); + } + } } diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts index 663ae5811..5cf64c4f5 100644 --- a/app/lib/stores/files.ts +++ b/app/lib/stores/files.ts @@ -8,6 +8,8 @@ import { WORK_DIR } from '~/utils/constants'; import { computeFileModifications } from '~/utils/diff'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; +import { versionHistoryStore } from './version-history'; +import { workbenchStore } from './workbench'; const logger = createScopedLogger('FilesStore'); @@ -29,22 +31,10 @@ export type FileMap = Record; export class FilesStore { #webcontainer: Promise; - - /** - * Tracks the number of files without folders. - */ #size = 0; - - /** - * @note Keeps track all modified files with their original content since the last user message. - * Needs to be reset when the user sends another message and all changes have to be submitted - * for the model to be aware of the changes. - */ #modifiedFiles: Map = import.meta.hot?.data.modifiedFiles ?? new Map(); - - /** - * Map of files that matches the state of WebContainer. - */ + #existingFiles: Set = new Set(); + #newFiles: Set = new Set(); files: MapStore = import.meta.hot?.data.files ?? map({}); get filesCount() { @@ -72,6 +62,10 @@ export class FilesStore { return dirent; } + isExistingFile(filePath: string) { + return this.#existingFiles.has(filePath); + } + getFileModifications() { return computeFileModifications(this.files.get(), this.#modifiedFiles); } @@ -80,7 +74,7 @@ export class FilesStore { this.#modifiedFiles.clear(); } - async saveFile(filePath: string, content: string) { + async saveFile(filePath: string, content: string, description: string = 'File updated') { const webcontainer = await this.#webcontainer; try { @@ -102,13 +96,21 @@ export class FilesStore { this.#modifiedFiles.set(filePath, oldContent); } - // we immediately update the file and don't rely on the `change` event coming from the watcher + // Add version to history + versionHistoryStore.addVersion(filePath, content, description); + + // Mark file as modified only if it existed before + if (this.#existingFiles.has(filePath)) { + const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get()); + newUnsavedFiles.add(filePath); + workbenchStore.unsavedFiles.set(newUnsavedFiles); + } + this.files.setKey(filePath, { type: 'file', content, isBinary: false }); logger.info('File updated'); } catch (error) { logger.error('Failed to update file content\n\n', error); - throw error; } } @@ -126,57 +128,72 @@ export class FilesStore { const watchEvents = events.flat(2); for (const { type, path, buffer } of watchEvents) { - // remove any trailing slashes const sanitizedPath = path.replace(/\/+$/g, ''); switch (type) { case 'add_dir': { - // we intentionally add a trailing slash so we can distinguish files from folders in the file tree this.files.setKey(sanitizedPath, { type: 'folder' }); break; } case 'remove_dir': { this.files.setKey(sanitizedPath, undefined); + this.#existingFiles.delete(sanitizedPath); for (const [direntPath] of Object.entries(this.files)) { if (direntPath.startsWith(sanitizedPath)) { this.files.setKey(direntPath, undefined); + this.#existingFiles.delete(direntPath); } } - break; } - case 'add_file': - case 'change': { - if (type === 'add_file') { - this.#size++; + case 'add_file': { + this.#size++; + let content = ''; + const isBinary = isBinaryFile(buffer); + + if (!isBinary) { + content = this.#decodeFileContent(buffer); + versionHistoryStore.addVersion(sanitizedPath, content, 'Initial version'); + + // Track as a new file + this.#newFiles.add(sanitizedPath); } + this.files.setKey(sanitizedPath, { type: 'file', content, isBinary }); + break; + } + case 'change': { let content = ''; - - /** - * @note This check is purely for the editor. The way we detect this is not - * bullet-proof and it's a best guess so there might be false-positives. - * The reason we do this is because we don't want to display binary files - * in the editor nor allow to edit them. - */ const isBinary = isBinaryFile(buffer); if (!isBinary) { content = this.#decodeFileContent(buffer); + + // If this is a new file's first change, mark it as existing + if (this.#newFiles.has(sanitizedPath)) { + this.#existingFiles.add(sanitizedPath); + this.#newFiles.delete(sanitizedPath); + } + // Only mark as modified if it's already an existing file + else if (this.#existingFiles.has(sanitizedPath)) { + const newUnsavedFiles = new Set(workbenchStore.unsavedFiles.get()); + newUnsavedFiles.add(sanitizedPath); + workbenchStore.unsavedFiles.set(newUnsavedFiles); + } } this.files.setKey(sanitizedPath, { type: 'file', content, isBinary }); - break; } case 'remove_file': { this.#size--; this.files.setKey(sanitizedPath, undefined); + this.#existingFiles.delete(sanitizedPath); + this.#newFiles.delete(sanitizedPath); break; } case 'update_directory': { - // we don't care about these events break; } } @@ -205,16 +222,8 @@ function isBinaryFile(buffer: Uint8Array | undefined) { return getEncoding(convertToBuffer(buffer), { chunkLength: 100 }) === 'binary'; } -/** - * Converts a `Uint8Array` into a Node.js `Buffer` by copying the prototype. - * The goal is to avoid expensive copies. It does create a new typed array - * but that's generally cheap as long as it uses the same underlying - * array buffer. - */ function convertToBuffer(view: Uint8Array): Buffer { const buffer = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); - Object.setPrototypeOf(buffer, Buffer.prototype); - return buffer as Buffer; } diff --git a/app/lib/stores/version-history.ts b/app/lib/stores/version-history.ts new file mode 100644 index 000000000..60f577b3a --- /dev/null +++ b/app/lib/stores/version-history.ts @@ -0,0 +1,123 @@ +import { map, type MapStore } from 'nanostores'; +import { createScopedLogger } from '~/utils/logger'; +import type { FilesStore } from './files'; + +const logger = createScopedLogger('VersionHistoryStore'); + +export interface FileVersion { + content: string; + timestamp: number; + description: string; +} + +export interface FileHistory { + versions: FileVersion[]; + currentVersion: number; +} + +type VersionMap = Record; + +export class VersionHistoryStore { + versions: MapStore = map({}); + #filesStore?: FilesStore; + + setFilesStore(filesStore: FilesStore) { + this.#filesStore = filesStore; + } + + addVersion(filePath: string, content: string, description: string) { + const currentHistory = this.versions.get()[filePath] || { versions: [], currentVersion: -1 }; + + // Don't add duplicate versions with the same content + const lastVersion = currentHistory.versions[currentHistory.versions.length - 1]; + if (lastVersion && lastVersion.content === content) { + return; + } + + const newVersion: FileVersion = { + content, + timestamp: Date.now(), + description + }; + + const newHistory: FileHistory = { + versions: [...currentHistory.versions, newVersion], + currentVersion: currentHistory.versions.length + }; + + this.versions.setKey(filePath, newHistory); + logger.info(`Added version for ${filePath}: ${description}`); + } + + getVersions(filePath: string): FileVersion[] { + const history = this.versions.get()[filePath]; + if (!history) { + // If no history exists, create initial version from current file content + if (this.#filesStore) { + const file = this.#filesStore.getFile(filePath); + if (file) { + this.addVersion(filePath, file.content, 'Initial version'); + return this.versions.get()[filePath]?.versions || []; + } + } + return []; + } + return history.versions; + } + + getCurrentVersion(filePath: string): FileVersion | undefined { + const history = this.versions.get()[filePath]; + if (!history) { + // If no history exists, create initial version from current file content + if (this.#filesStore) { + const file = this.#filesStore.getFile(filePath); + if (file) { + this.addVersion(filePath, file.content, 'Initial version'); + return this.versions.get()[filePath]?.versions[0]; + } + } + return undefined; + } + return history.versions[history.currentVersion]; + } + + getVersion(filePath: string, versionIndex: number): FileVersion | undefined { + return this.versions.get()[filePath]?.versions[versionIndex]; + } + + async revertToVersion(filePath: string, versionIndex: number): Promise { + const history = this.versions.get()[filePath]; + if (!history || versionIndex < 0 || versionIndex >= history.versions.length) { + return undefined; + } + + const version = history.versions[versionIndex]; + if (!version) { + return undefined; + } + + // Update the file content using FilesStore + if (this.#filesStore) { + try { + await this.#filesStore.saveFile(filePath, version.content, `Reverted to version ${versionIndex + 1}`); + + const newHistory: FileHistory = { + ...history, + currentVersion: versionIndex + }; + + this.versions.setKey(filePath, newHistory); + logger.info(`Reverted ${filePath} to version ${versionIndex + 1}`); + return version; + } catch (error) { + logger.error(`Failed to revert ${filePath} to version ${versionIndex + 1}:`, error); + throw error; + } + } else { + logger.error('FilesStore not initialized'); + throw new Error('FilesStore not initialized'); + } + } +} + +export const versionHistoryStore = new VersionHistoryStore(); diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index c42cc6275..76a9db139 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -132,6 +132,10 @@ export class WorkbenchStore { if (unsavedChanges) { newUnsavedFiles.add(currentDocument.filePath); + // Only mark as modified if the file exists + if (this.#filesStore.isExistingFile(currentDocument.filePath)) { + this.modifiedFiles.add(currentDocument.filePath); + } } else { newUnsavedFiles.delete(currentDocument.filePath); } @@ -190,13 +194,8 @@ export class WorkbenchStore { } const { filePath } = currentDocument; - const file = this.#filesStore.getFile(filePath); - - if (!file) { - return; - } - - this.setCurrentDocumentContent(file.content); + this.#editorStore.resetFile(filePath); + this.modifiedFiles.delete(filePath); } async saveAllFiles() { @@ -206,11 +205,12 @@ export class WorkbenchStore { } getFileModifcations() { - return this.#filesStore.getFileModifications(); + return Array.from(this.modifiedFiles); } resetAllFileModifications() { - this.#filesStore.resetFileModifications(); + this.modifiedFiles.clear(); + this.unsavedFiles.set(new Set()); } abortAllActions() { @@ -232,7 +232,7 @@ export class WorkbenchStore { id, title, closed: false, - runner: new ActionRunner(webcontainer), + runner: new ActionRunner(webcontainer, this.#filesStore), }); } @@ -247,7 +247,7 @@ export class WorkbenchStore { } async addAction(data: ActionCallbackData) { - const { messageId } = data; + const { messageId, action } = data; const artifact = this.#getArtifact(messageId); @@ -255,6 +255,20 @@ export class WorkbenchStore { unreachable('Artifact not found'); } + // Track file modifications for file actions, but only for existing files + if (action.type === 'file') { + const filePath = action.filePath; + // Only track modifications for existing files + if (this.#filesStore.isExistingFile(filePath)) { + this.modifiedFiles.add(filePath); + + // Update unsavedFiles since this is a modification of an existing file + const newUnsavedFiles = new Set(this.unsavedFiles.get()); + newUnsavedFiles.add(filePath); + this.unsavedFiles.set(newUnsavedFiles); + } + } + artifact.runner.addAction(data); } @@ -336,7 +350,6 @@ export class WorkbenchStore { } async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) { - try { // Get the GitHub auth token from environment variables const githubToken = ghToken; @@ -351,7 +364,7 @@ export class WorkbenchStore { const octokit = new Octokit({ auth: githubToken }); // Check if the repository already exists before creating it - let repo + let repo; try { repo = await octokit.repos.get({ owner: owner, repo: repoName }); } catch (error) { @@ -362,7 +375,7 @@ export class WorkbenchStore { private: false, auto_init: true, }); - repo = newRepo; + repo = { owner: { login: owner }, name: repoName, default_branch: 'main', ...newRepo }; } else { console.log('cannot create repo!'); throw error; // Some other error occurred @@ -380,8 +393,8 @@ export class WorkbenchStore { Object.entries(files).map(async ([filePath, dirent]) => { if (dirent?.type === 'file' && dirent.content) { const { data: blob } = await octokit.git.createBlob({ - owner: repo.owner.login, - repo: repo.name, + owner: owner, + repo: repoName, content: Buffer.from(dirent.content).toString('base64'), encoding: 'base64', }); @@ -398,16 +411,16 @@ export class WorkbenchStore { // Get the latest commit SHA (assuming main branch, update dynamically if needed) const { data: ref } = await octokit.git.getRef({ - owner: repo.owner.login, - repo: repo.name, + owner: owner, + repo: repoName, ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch }); const latestCommitSha = ref.object.sha; // Create a new tree const { data: newTree } = await octokit.git.createTree({ - owner: repo.owner.login, - repo: repo.name, + owner: owner, + repo: repoName, base_tree: latestCommitSha, tree: validBlobs.map((blob) => ({ path: blob!.path, @@ -419,8 +432,8 @@ export class WorkbenchStore { // Create a new commit const { data: newCommit } = await octokit.git.createCommit({ - owner: repo.owner.login, - repo: repo.name, + owner: owner, + repo: repoName, message: 'Initial commit from your app', tree: newTree.sha, parents: [latestCommitSha], @@ -428,15 +441,16 @@ export class WorkbenchStore { // Update the reference await octokit.git.updateRef({ - owner: repo.owner.login, - repo: repo.name, + owner: owner, + repo: repoName, ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch sha: newCommit.sha, }); - alert(`Repository created and code pushed: ${repo.html_url}`); + return repo.html_url; } catch (error) { console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error)); + throw error; } } } From d25cdc2c7dc5cfc9a52985348a0f4e328e239222 Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 17:39:45 -0500 Subject: [PATCH 02/12] feat: add prompt caching with visual indicator --- app/components/chat/BaseChat.tsx | 13 +++++-- app/components/chat/Chat.client.tsx | 3 +- app/lib/hooks/usePromptEnhancer.ts | 15 +++++++- app/lib/stores/prompt-cache.ts | 54 +++++++++++++++++++++++++++++ app/routes/api.enhancer.ts | 30 +++++++++++++++- 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 app/lib/stores/prompt-cache.ts diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index c1175f700..5d51278eb 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -76,6 +76,7 @@ interface BaseChatProps { messages?: Message[]; enhancingPrompt?: boolean; promptEnhanced?: boolean; + fromCache?: boolean; input?: string; model: string; setModel: (model: string) => void; @@ -96,6 +97,7 @@ export const BaseChat = React.forwardRef( isStreaming = false, enhancingPrompt = false, promptEnhanced = false, + fromCache = false, messages, input = '', model, @@ -224,8 +226,15 @@ export const BaseChat = React.forwardRef( ) : ( <> -
- {promptEnhanced &&
Prompt enhanced
} +
+ {promptEnhanced && ( +
+ {fromCache ? "From cache" : "Prompt enhanced"} +
+ )} )} diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 458bd8364..32f480c90 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -91,7 +91,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp initialMessages, }); - const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); + const { enhancingPrompt, promptEnhanced, fromCache, enhancePrompt, resetEnhancer } = usePromptEnhancer(); const { parsedMessages, parseMessages } = useMessageParser(); const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; @@ -212,6 +212,7 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp isStreaming={isLoading} enhancingPrompt={enhancingPrompt} promptEnhanced={promptEnhanced} + fromCache={fromCache} sendMessage={sendMessage} model={model} setModel={setModel} diff --git a/app/lib/hooks/usePromptEnhancer.ts b/app/lib/hooks/usePromptEnhancer.ts index f376cc0cd..4eed04b5c 100644 --- a/app/lib/hooks/usePromptEnhancer.ts +++ b/app/lib/hooks/usePromptEnhancer.ts @@ -6,15 +6,18 @@ const logger = createScopedLogger('usePromptEnhancement'); export function usePromptEnhancer() { const [enhancingPrompt, setEnhancingPrompt] = useState(false); const [promptEnhanced, setPromptEnhanced] = useState(false); + const [fromCache, setFromCache] = useState(false); const resetEnhancer = () => { setEnhancingPrompt(false); setPromptEnhanced(false); + setFromCache(false); }; const enhancePrompt = async (input: string, setInput: (value: string) => void) => { setEnhancingPrompt(true); setPromptEnhanced(false); + setFromCache(false); const response = await fetch('/api/enhancer', { method: 'POST', @@ -23,6 +26,10 @@ export function usePromptEnhancer() { }), }); + // Check if response was from cache + const isCached = response.headers.get('x-from-cache') === 'true'; + setFromCache(isCached); + const reader = response.body?.getReader(); const originalInput = input; @@ -67,5 +74,11 @@ export function usePromptEnhancer() { } }; - return { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer }; + return { + enhancingPrompt, + promptEnhanced, + fromCache, + enhancePrompt, + resetEnhancer + }; } diff --git a/app/lib/stores/prompt-cache.ts b/app/lib/stores/prompt-cache.ts new file mode 100644 index 000000000..fc59a1db5 --- /dev/null +++ b/app/lib/stores/prompt-cache.ts @@ -0,0 +1,54 @@ +import { map, type MapStore } from 'nanostores'; + +interface CacheEntry { + enhancedPrompt: string; + timestamp: number; +} + +type PromptCache = MapStore>; + +class PromptCacheStore { + // Cache entries expire after 24 hours + private static CACHE_TTL = 24 * 60 * 60 * 1000; + + cache: PromptCache = map({}); + + getEnhancedPrompt(originalPrompt: string): string | null { + const entry = this.cache.get()[originalPrompt]; + if (!entry) return null; + + // Check if cache entry has expired + if (Date.now() - entry.timestamp > PromptCacheStore.CACHE_TTL) { + this.removeFromCache(originalPrompt); + return null; + } + + return entry.enhancedPrompt; + } + + addToCache(originalPrompt: string, enhancedPrompt: string) { + this.cache.setKey(originalPrompt, { + enhancedPrompt, + timestamp: Date.now(), + }); + } + + removeFromCache(originalPrompt: string) { + const entries = this.cache.get(); + delete entries[originalPrompt]; + this.cache.set(entries); + } + + clearExpiredEntries() { + const entries = this.cache.get(); + const now = Date.now(); + + Object.entries(entries).forEach(([prompt, entry]) => { + if (now - entry.timestamp > PromptCacheStore.CACHE_TTL) { + this.removeFromCache(prompt); + } + }); + } +} + +export const promptCacheStore = new PromptCacheStore(); diff --git a/app/routes/api.enhancer.ts b/app/routes/api.enhancer.ts index 5c8175ca3..95c1af91e 100644 --- a/app/routes/api.enhancer.ts +++ b/app/routes/api.enhancer.ts @@ -2,6 +2,7 @@ import { type ActionFunctionArgs } from '@remix-run/cloudflare'; import { StreamingTextResponse, parseStreamPart } from 'ai'; import { streamText } from '~/lib/.server/llm/stream-text'; import { stripIndents } from '~/utils/stripIndent'; +import { promptCacheStore } from '~/lib/stores/prompt-cache'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -14,6 +15,22 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) { const { message } = await request.json<{ message: string }>(); try { + // Check cache first + const cachedPrompt = promptCacheStore.getEnhancedPrompt(message); + if (cachedPrompt) { + // Return cached result immediately with cache header + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(cachedPrompt)); + controller.close(); + }, + }); + const response = new StreamingTextResponse(stream); + response.headers.set('x-from-cache', 'true'); + return response; + } + + // If not in cache, proceed with LLM call const result = await streamText( [ { @@ -32,6 +49,8 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) { context.cloudflare.env, ); + let enhancedPrompt = ''; + const transformStream = new TransformStream({ transform(chunk, controller) { const processedChunk = decoder @@ -42,13 +61,22 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) { .map((part) => part.value) .join(''); + // Accumulate the enhanced prompt + enhancedPrompt += processedChunk; + controller.enqueue(encoder.encode(processedChunk)); }, + flush() { + // Cache the complete enhanced prompt + promptCacheStore.addToCache(message, enhancedPrompt); + } }); const transformedStream = result.toAIStream().pipeThrough(transformStream); + const response = new StreamingTextResponse(transformedStream); + response.headers.set('x-from-cache', 'false'); + return response; - return new StreamingTextResponse(transformedStream); } catch (error) { console.log(error); From ae72cd4665d924d28f9162ab7169b4aa781c67a2 Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 17:54:01 -0500 Subject: [PATCH 03/12] fix: improve chat persistence reliability and error handling --- app/lib/persistence/db.ts | 264 +++++++++++++++++--------- app/lib/persistence/useChatHistory.ts | 67 ++++--- 2 files changed, 209 insertions(+), 122 deletions(-) diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index 7a952e344..80d895567 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -4,40 +4,81 @@ import type { ChatHistoryItem } from './useChatHistory'; const logger = createScopedLogger('ChatHistory'); -// this is used at the top level and never rejects +function isBrowserEnvironment(): boolean { + return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'; +} + export async function openDatabase(): Promise { return new Promise((resolve) => { - const request = indexedDB.open('boltHistory', 1); - - request.onupgradeneeded = (event: IDBVersionChangeEvent) => { - const db = (event.target as IDBOpenDBRequest).result; - - if (!db.objectStoreNames.contains('chats')) { - const store = db.createObjectStore('chats', { keyPath: 'id' }); - store.createIndex('id', 'id', { unique: true }); - store.createIndex('urlId', 'urlId', { unique: true }); + try { + // Check if we're in a browser environment with IndexedDB support + if (!isBrowserEnvironment()) { + logger.debug('Not in browser environment or IndexedDB not available'); + resolve(undefined); + return; } - }; - request.onsuccess = (event: Event) => { - resolve((event.target as IDBOpenDBRequest).result); - }; - - request.onerror = (event: Event) => { + const request = indexedDB.open('boltHistory', 1); + + request.onupgradeneeded = (event: IDBVersionChangeEvent) => { + const db = (event.target as IDBOpenDBRequest).result; + logger.debug('Upgrading database'); + + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + logger.debug('Created chats store'); + } + }; + + request.onsuccess = (event: Event) => { + const db = (event.target as IDBOpenDBRequest).result; + logger.debug('Successfully opened database'); + + // Add error handler for database + db.onerror = (event: Event) => { + const target = event.target as IDBDatabase; + logger.error('Database error:', target.name); + }; + + resolve(db); + }; + + request.onerror = (event: Event) => { + const error = (event.target as IDBOpenDBRequest).error; + logger.error('Failed to open database:', error?.message || 'Unknown error'); + resolve(undefined); + }; + + request.onblocked = () => { + logger.error('Database blocked'); + resolve(undefined); + }; + + } catch (error) { + logger.error('Error initializing database:', error); resolve(undefined); - logger.error((event.target as IDBOpenDBRequest).error); - }; + } }); } export async function getAll(db: IDBDatabase): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction('chats', 'readonly'); - const store = transaction.objectStore('chats'); - const request = store.getAll(); - - request.onsuccess = () => resolve(request.result as ChatHistoryItem[]); - request.onerror = () => reject(request.error); + try { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result as ChatHistoryItem[]); + request.onerror = () => { + logger.error('Failed to get all chats:', request.error); + reject(request.error); + }; + } catch (error) { + logger.error('Error getting all chats:', error); + reject(error); + } }); } @@ -49,19 +90,30 @@ export async function setMessages( description?: string, ): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction('chats', 'readwrite'); - const store = transaction.objectStore('chats'); - - const request = store.put({ - id, - messages, - urlId, - description, - timestamp: new Date().toISOString(), - }); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + try { + const transaction = db.transaction('chats', 'readwrite'); + const store = transaction.objectStore('chats'); + + const request = store.put({ + id, + messages, + urlId, + description, + timestamp: new Date().toISOString(), + }); + + request.onsuccess = () => { + logger.debug('Successfully stored messages'); + resolve(); + }; + request.onerror = () => { + logger.error('Failed to store messages:', request.error); + reject(request.error); + }; + } catch (error) { + logger.error('Error storing messages:', error); + reject(error); + } }); } @@ -71,50 +123,81 @@ export async function getMessages(db: IDBDatabase, id: string): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction('chats', 'readonly'); - const store = transaction.objectStore('chats'); - const index = store.index('urlId'); - const request = index.get(id); - - request.onsuccess = () => resolve(request.result as ChatHistoryItem); - request.onerror = () => reject(request.error); + try { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const index = store.index('urlId'); + const request = index.get(id); + + request.onsuccess = () => resolve(request.result as ChatHistoryItem); + request.onerror = () => { + logger.error('Failed to get messages by URL ID:', request.error); + reject(request.error); + }; + } catch (error) { + logger.error('Error getting messages by URL ID:', error); + reject(error); + } }); } export async function getMessagesById(db: IDBDatabase, id: string): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction('chats', 'readonly'); - const store = transaction.objectStore('chats'); - const request = store.get(id); - - request.onsuccess = () => resolve(request.result as ChatHistoryItem); - request.onerror = () => reject(request.error); + try { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const request = store.get(id); + + request.onsuccess = () => resolve(request.result as ChatHistoryItem); + request.onerror = () => { + logger.error('Failed to get messages by ID:', request.error); + reject(request.error); + }; + } catch (error) { + logger.error('Error getting messages by ID:', error); + reject(error); + } }); } export async function deleteById(db: IDBDatabase, id: string): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction('chats', 'readwrite'); - const store = transaction.objectStore('chats'); - const request = store.delete(id); - - request.onsuccess = () => resolve(undefined); - request.onerror = () => reject(request.error); + try { + const transaction = db.transaction('chats', 'readwrite'); + const store = transaction.objectStore('chats'); + const request = store.delete(id); + + request.onsuccess = () => resolve(undefined); + request.onerror = () => { + logger.error('Failed to delete chat:', request.error); + reject(request.error); + }; + } catch (error) { + logger.error('Error deleting chat:', error); + reject(error); + } }); } export async function getNextId(db: IDBDatabase): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction('chats', 'readonly'); - const store = transaction.objectStore('chats'); - const request = store.getAllKeys(); - - request.onsuccess = () => { - const highestId = request.result.reduce((cur, acc) => Math.max(+cur, +acc), 0); - resolve(String(+highestId + 1)); - }; - - request.onerror = () => reject(request.error); + try { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const request = store.getAllKeys(); + + request.onsuccess = () => { + const highestId = request.result.reduce((cur, acc) => Math.max(+cur, +acc), 0); + resolve(String(+highestId + 1)); + }; + request.onerror = () => { + logger.error('Failed to get next ID:', request.error); + reject(request.error); + }; + } catch (error) { + logger.error('Error getting next ID:', error); + reject(error); + } }); } @@ -125,36 +208,41 @@ export async function getUrlId(db: IDBDatabase, id: string): Promise { return id; } else { let i = 2; - while (idList.includes(`${id}-${i}`)) { i++; } - return `${id}-${i}`; } } async function getUrlIds(db: IDBDatabase): Promise { return new Promise((resolve, reject) => { - const transaction = db.transaction('chats', 'readonly'); - const store = transaction.objectStore('chats'); - const idList: string[] = []; - - const request = store.openCursor(); - - request.onsuccess = (event: Event) => { - const cursor = (event.target as IDBRequest).result; - - if (cursor) { - idList.push(cursor.value.urlId); - cursor.continue(); - } else { - resolve(idList); - } - }; - - request.onerror = () => { - reject(request.error); - }; + try { + const transaction = db.transaction('chats', 'readonly'); + const store = transaction.objectStore('chats'); + const idList: string[] = []; + + const request = store.openCursor(); + + request.onsuccess = (event: Event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + if (cursor.value.urlId) { + idList.push(cursor.value.urlId); + } + cursor.continue(); + } else { + resolve(idList); + } + }; + + request.onerror = () => { + logger.error('Failed to get URL IDs:', request.error); + reject(request.error); + }; + } catch (error) { + logger.error('Error getting URL IDs:', error); + reject(error); + } }); } diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index e56275327..63125232a 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -14,9 +14,8 @@ export interface ChatHistoryItem { timestamp: string; } -const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE; - -export const db = persistenceEnabled ? await openDatabase() : undefined; +// Initialize database lazily when component mounts +let db: IDBDatabase | undefined; export const chatId = atom(undefined); export const description = atom(undefined); @@ -29,36 +28,40 @@ export function useChatHistory() { const [ready, setReady] = useState(false); const [urlId, setUrlId] = useState(); + // Initialize database when component mounts useEffect(() => { - if (!db) { - setReady(true); - - if (persistenceEnabled) { - toast.error(`Chat persistence is unavailable`); + const initDb = async () => { + if (!db) { + db = await openDatabase(); + } + + if (!db) { + setReady(true); + // Only show error if database failed to open + toast.error('Failed to initialize chat persistence'); + return; } - return; - } - - if (mixedId) { - getMessages(db, mixedId) - .then((storedMessages) => { + if (mixedId) { + try { + const storedMessages = await getMessages(db, mixedId); if (storedMessages && storedMessages.messages.length > 0) { setInitialMessages(storedMessages.messages); setUrlId(storedMessages.urlId); description.set(storedMessages.description); chatId.set(storedMessages.id); } else { - navigate(`/`, { replace: true }); + navigate('/', { replace: true }); } + } catch (error) { + toast.error((error as Error).message); + } + } + setReady(true); + }; - setReady(true); - }) - .catch((error) => { - toast.error(error.message); - }); - } - }, []); + initDb(); + }, [mixedId, navigate]); return { ready: !mixedId || ready, @@ -71,10 +74,9 @@ export function useChatHistory() { const { firstArtifact } = workbenchStore; if (!urlId && firstArtifact?.id) { - const urlId = await getUrlId(db, firstArtifact.id); - - navigateChat(urlId); - setUrlId(urlId); + const newUrlId = await getUrlId(db, firstArtifact.id); + navigateChat(newUrlId); + setUrlId(newUrlId); } if (!description.get() && firstArtifact?.title) { @@ -83,7 +85,6 @@ export function useChatHistory() { if (initialMessages.length === 0 && !chatId.get()) { const nextId = await getNextId(db); - chatId.set(nextId); if (!urlId) { @@ -91,19 +92,17 @@ export function useChatHistory() { } } - await setMessages(db, chatId.get() as string, messages, urlId, description.get()); + try { + await setMessages(db, chatId.get() as string, messages, urlId, description.get()); + } catch (error) { + toast.error('Failed to save chat history'); + } }, }; } function navigateChat(nextId: string) { - /** - * FIXME: Using the intended navigate function causes a rerender for that breaks the app. - * - * `navigate(`/chat/${nextId}`, { replace: true });` - */ const url = new URL(window.location.href); url.pathname = `/chat/${nextId}`; - window.history.replaceState({}, '', url); } From e7b69ecb43a1922ef62e8675098945e8a79c621c Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 17:59:23 -0500 Subject: [PATCH 04/12] fix: improve chat persistence error handling --- app/components/chat/Chat.client.tsx | 48 ++++++--------------------- app/lib/persistence/useChatHistory.ts | 16 ++++----- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 32f480c90..c5d0dbeed 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -28,9 +28,10 @@ export function Chat() { const { ready, initialMessages, storeMessageHistory } = useChatHistory(); - return ( + // Only show the chat component if we're ready + return ready ? ( <> - {ready && } + { return ( @@ -40,9 +41,6 @@ export function Chat() { ); }} icon={({ type }) => { - /** - * @todo Handle more types if we need them. This may require extra color palettes. - */ switch (type) { case 'success': { return
; @@ -51,7 +49,6 @@ export function Chat() { return
; } } - return undefined; }} position="bottom-right" @@ -59,7 +56,7 @@ export function Chat() { transition={toastAnimation} /> - ); + ) : null; } interface ChatProps { @@ -104,13 +101,15 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp parseMessages(messages, isLoading); if (messages.length > initialMessages.length) { - storeMessageHistory(messages).catch((error) => toast.error(error.message)); + storeMessageHistory(messages).catch((error) => { + logger.error('Failed to store message history:', error); + // Don't show error toast for persistence failures + }); } - }, [messages, isLoading, parseMessages]); + }, [messages, isLoading, parseMessages, storeMessageHistory, initialMessages.length]); const scrollTextArea = () => { const textarea = textareaRef.current; - if (textarea) { textarea.scrollTop = textarea.scrollHeight; } @@ -124,16 +123,13 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp useEffect(() => { const textarea = textareaRef.current; - if (textarea) { textarea.style.height = 'auto'; - const scrollHeight = textarea.scrollHeight; - textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; } - }, [input, textareaRef]); + }, [input, TEXTAREA_MAX_HEIGHT]); const runAnimation = async () => { if (chatStarted) { @@ -146,7 +142,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp ]); chatStore.setKey('started', true); - setChatStarted(true); }; @@ -157,13 +152,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp return; } - /** - * @note (delm) Usually saving files shouldn't take long but it may take longer if there - * many unsaved files. In that case we need to block user input and show an indicator - * of some kind so the user is aware that something is happening. But I consider the - * happy case to be no unsaved files and I would expect users to save their changes - * before they send another message. - */ await workbenchStore.saveAllFiles(); const fileModifications = workbenchStore.getFileModifcations(); @@ -174,29 +162,14 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp if (fileModifications !== undefined) { const diff = fileModificationsToHTML(fileModifications); - - /** - * If we have file modifications we append a new user message manually since we have to prefix - * the user input with the file modifications and we don't want the new user input to appear - * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to - * manually reset the input and we'd have to manually pass in file attachments. However, those - * aren't relevant here. - */ append({ role: 'user', content: `[Model: ${model}]\n\n${diff}\n\n${_input}` }); - - /** - * After sending a new message we reset all modifications since the model - * should now be aware of all the changes. - */ workbenchStore.resetAllFileModifications(); } else { append({ role: 'user', content: `[Model: ${model}]\n\n${_input}` }); } setInput(''); - resetEnhancer(); - textareaRef.current?.blur(); }; @@ -224,7 +197,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp if (message.role === 'user') { return message; } - return { ...message, content: parsedMessages[i] || '', diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index 63125232a..bf02a39b1 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -14,7 +14,7 @@ export interface ChatHistoryItem { timestamp: string; } -// Initialize database lazily when component mounts +// Remove environment check and persistence flag let db: IDBDatabase | undefined; export const chatId = atom(undefined); @@ -27,18 +27,18 @@ export function useChatHistory() { const [initialMessages, setInitialMessages] = useState([]); const [ready, setReady] = useState(false); const [urlId, setUrlId] = useState(); + const [dbInitialized, setDbInitialized] = useState(false); // Initialize database when component mounts useEffect(() => { const initDb = async () => { - if (!db) { + if (!db && !dbInitialized) { db = await openDatabase(); + setDbInitialized(true); } - + if (!db) { setReady(true); - // Only show error if database failed to open - toast.error('Failed to initialize chat persistence'); return; } @@ -54,14 +54,14 @@ export function useChatHistory() { navigate('/', { replace: true }); } } catch (error) { - toast.error((error as Error).message); + console.error('Failed to load messages:', error); } } setReady(true); }; initDb(); - }, [mixedId, navigate]); + }, [mixedId, navigate, dbInitialized]); return { ready: !mixedId || ready, @@ -95,7 +95,7 @@ export function useChatHistory() { try { await setMessages(db, chatId.get() as string, messages, urlId, description.get()); } catch (error) { - toast.error('Failed to save chat history'); + console.error('Failed to store messages:', error); } }, }; From 895fb7a2e0660531caecbbfda828c3aa66160f06 Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:01:16 -0500 Subject: [PATCH 05/12] fix: improve database initialization and error handling --- app/lib/persistence/db.ts | 42 +++++++++--- app/lib/persistence/useChatHistory.ts | 97 ++++++++++++++++----------- 2 files changed, 89 insertions(+), 50 deletions(-) diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index 80d895567..d12d7e5dc 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -4,14 +4,30 @@ import type { ChatHistoryItem } from './useChatHistory'; const logger = createScopedLogger('ChatHistory'); +let dbInitAttempted = false; + function isBrowserEnvironment(): boolean { - return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'; + try { + return typeof window !== 'undefined' && + typeof window.indexedDB !== 'undefined' && + typeof window.IDBDatabase !== 'undefined' && + typeof window.IDBTransaction !== 'undefined'; + } catch (error) { + logger.error('Error checking browser environment:', error); + return false; + } } export async function openDatabase(): Promise { + if (dbInitAttempted) { + logger.debug('Database initialization already attempted'); + return undefined; + } + + dbInitAttempted = true; + return new Promise((resolve) => { try { - // Check if we're in a browser environment with IndexedDB support if (!isBrowserEnvironment()) { logger.debug('Not in browser environment or IndexedDB not available'); resolve(undefined); @@ -36,13 +52,21 @@ export async function openDatabase(): Promise { const db = (event.target as IDBOpenDBRequest).result; logger.debug('Successfully opened database'); - // Add error handler for database - db.onerror = (event: Event) => { - const target = event.target as IDBDatabase; - logger.error('Database error:', target.name); - }; - - resolve(db); + // Test if we can actually use the database + try { + const transaction = db.transaction(['chats'], 'readonly'); + transaction.oncomplete = () => { + logger.debug('Database test successful'); + resolve(db); + }; + transaction.onerror = () => { + logger.error('Database test failed'); + resolve(undefined); + }; + } catch (error) { + logger.error('Error testing database:', error); + resolve(undefined); + } }; request.onerror = (event: Event) => { diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index bf02a39b1..18bda021d 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -2,9 +2,11 @@ import { useLoaderData, useNavigate } from '@remix-run/react'; import { useState, useEffect } from 'react'; import { atom } from 'nanostores'; import type { Message } from 'ai'; -import { toast } from 'react-toastify'; import { workbenchStore } from '~/lib/stores/workbench'; import { getMessages, getNextId, getUrlId, openDatabase, setMessages } from './db'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('ChatHistory'); export interface ChatHistoryItem { id: string; @@ -14,8 +16,9 @@ export interface ChatHistoryItem { timestamp: string; } -// Remove environment check and persistence flag +// Initialize database lazily when needed let db: IDBDatabase | undefined; +let dbInitialized = false; export const chatId = atom(undefined); export const description = atom(undefined); @@ -27,41 +30,53 @@ export function useChatHistory() { const [initialMessages, setInitialMessages] = useState([]); const [ready, setReady] = useState(false); const [urlId, setUrlId] = useState(); - const [dbInitialized, setDbInitialized] = useState(false); // Initialize database when component mounts useEffect(() => { const initDb = async () => { - if (!db && !dbInitialized) { - db = await openDatabase(); - setDbInitialized(true); - } + try { + // Only attempt to initialize once + if (!dbInitialized) { + logger.debug('Initializing database'); + db = await openDatabase(); + dbInitialized = true; + } - if (!db) { - setReady(true); - return; - } + // If we have a mixedId but no database, navigate home + if (mixedId && !db) { + logger.debug('No database available, navigating home'); + navigate('/', { replace: true }); + setReady(true); + return; + } - if (mixedId) { - try { - const storedMessages = await getMessages(db, mixedId); - if (storedMessages && storedMessages.messages.length > 0) { - setInitialMessages(storedMessages.messages); - setUrlId(storedMessages.urlId); - description.set(storedMessages.description); - chatId.set(storedMessages.id); - } else { + // If we have both mixedId and database, try to load messages + if (mixedId && db) { + try { + const storedMessages = await getMessages(db, mixedId); + if (storedMessages && storedMessages.messages.length > 0) { + setInitialMessages(storedMessages.messages); + setUrlId(storedMessages.urlId); + description.set(storedMessages.description); + chatId.set(storedMessages.id); + } else { + navigate('/', { replace: true }); + } + } catch (error) { + logger.error('Failed to load messages:', error); navigate('/', { replace: true }); } - } catch (error) { - console.error('Failed to load messages:', error); } + + setReady(true); + } catch (error) { + logger.error('Failed to initialize:', error); + setReady(true); } - setReady(true); }; initDb(); - }, [mixedId, navigate, dbInitialized]); + }, [mixedId, navigate]); return { ready: !mixedId || ready, @@ -71,31 +86,31 @@ export function useChatHistory() { return; } - const { firstArtifact } = workbenchStore; + try { + const { firstArtifact } = workbenchStore; - if (!urlId && firstArtifact?.id) { - const newUrlId = await getUrlId(db, firstArtifact.id); - navigateChat(newUrlId); - setUrlId(newUrlId); - } + if (!urlId && firstArtifact?.id) { + const newUrlId = await getUrlId(db, firstArtifact.id); + navigateChat(newUrlId); + setUrlId(newUrlId); + } - if (!description.get() && firstArtifact?.title) { - description.set(firstArtifact?.title); - } + if (!description.get() && firstArtifact?.title) { + description.set(firstArtifact?.title); + } - if (initialMessages.length === 0 && !chatId.get()) { - const nextId = await getNextId(db); - chatId.set(nextId); + if (initialMessages.length === 0 && !chatId.get()) { + const nextId = await getNextId(db); + chatId.set(nextId); - if (!urlId) { - navigateChat(nextId); + if (!urlId) { + navigateChat(nextId); + } } - } - try { await setMessages(db, chatId.get() as string, messages, urlId, description.get()); } catch (error) { - console.error('Failed to store messages:', error); + logger.error('Failed to store messages:', error); } }, }; From 02030cb87594517b4eb53d6efb6cf97440541f1d Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:01:57 -0500 Subject: [PATCH 06/12] fix: remove persistence error toast and improve error handling --- app/components/chat/Chat.client.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index c5d0dbeed..a5d2ca786 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -5,7 +5,7 @@ import type { Message } from 'ai'; import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { memo, useEffect, useRef, useState } from 'react'; -import { cssTransition, toast, ToastContainer } from 'react-toastify'; +import { cssTransition, ToastContainer } from 'react-toastify'; import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; import { useChatHistory } from '~/lib/persistence'; import { chatStore } from '~/lib/stores/chat'; @@ -80,7 +80,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp api: '/api/chat', onError: (error) => { logger.error('Request failed\n\n', error); - toast.error('There was an error processing your request'); }, onFinish: () => { logger.debug('Finished streaming'); @@ -103,7 +102,6 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp if (messages.length > initialMessages.length) { storeMessageHistory(messages).catch((error) => { logger.error('Failed to store message history:', error); - // Don't show error toast for persistence failures }); } }, [messages, isLoading, parseMessages, storeMessageHistory, initialMessages.length]); From fb156fa7beeb190a749f152933b3f9027e6d38de Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:06:46 -0500 Subject: [PATCH 07/12] fix: improve database initialization and error handling --- app/lib/persistence/db.ts | 21 +++++++-- app/lib/persistence/useChatHistory.ts | 66 +++++++++++++-------------- 2 files changed, 50 insertions(+), 37 deletions(-) diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index d12d7e5dc..1199ff7b8 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -5,6 +5,7 @@ import type { ChatHistoryItem } from './useChatHistory'; const logger = createScopedLogger('ChatHistory'); let dbInitAttempted = false; +let dbInitializing = false; function isBrowserEnvironment(): boolean { try { @@ -19,17 +20,19 @@ function isBrowserEnvironment(): boolean { } export async function openDatabase(): Promise { - if (dbInitAttempted) { - logger.debug('Database initialization already attempted'); + if (dbInitAttempted || dbInitializing) { + logger.debug('Database initialization already attempted or in progress'); return undefined; } - dbInitAttempted = true; + dbInitializing = true; return new Promise((resolve) => { try { if (!isBrowserEnvironment()) { logger.debug('Not in browser environment or IndexedDB not available'); + dbInitAttempted = true; + dbInitializing = false; resolve(undefined); return; } @@ -57,14 +60,20 @@ export async function openDatabase(): Promise { const transaction = db.transaction(['chats'], 'readonly'); transaction.oncomplete = () => { logger.debug('Database test successful'); + dbInitAttempted = true; + dbInitializing = false; resolve(db); }; transaction.onerror = () => { logger.error('Database test failed'); + dbInitAttempted = true; + dbInitializing = false; resolve(undefined); }; } catch (error) { logger.error('Error testing database:', error); + dbInitAttempted = true; + dbInitializing = false; resolve(undefined); } }; @@ -72,16 +81,22 @@ export async function openDatabase(): Promise { request.onerror = (event: Event) => { const error = (event.target as IDBOpenDBRequest).error; logger.error('Failed to open database:', error?.message || 'Unknown error'); + dbInitAttempted = true; + dbInitializing = false; resolve(undefined); }; request.onblocked = () => { logger.error('Database blocked'); + dbInitAttempted = true; + dbInitializing = false; resolve(undefined); }; } catch (error) { logger.error('Error initializing database:', error); + dbInitAttempted = true; + dbInitializing = false; resolve(undefined); } }); diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index 18bda021d..eddf17509 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -19,10 +19,29 @@ export interface ChatHistoryItem { // Initialize database lazily when needed let db: IDBDatabase | undefined; let dbInitialized = false; +let dbInitializing = false; export const chatId = atom(undefined); export const description = atom(undefined); +async function initializeDb() { + if (dbInitialized || dbInitializing) { + return db; + } + + dbInitializing = true; + try { + db = await openDatabase(); + dbInitialized = true; + logger.debug('Database initialized successfully'); + } catch (error) { + logger.error('Failed to initialize database:', error); + } finally { + dbInitializing = false; + } + return db; +} + export function useChatHistory() { const navigate = useNavigate(); const { id: mixedId } = useLoaderData<{ id?: string }>(); @@ -33,49 +52,28 @@ export function useChatHistory() { // Initialize database when component mounts useEffect(() => { - const initDb = async () => { + const init = async () => { try { - // Only attempt to initialize once - if (!dbInitialized) { - logger.debug('Initializing database'); - db = await openDatabase(); - dbInitialized = true; - } - - // If we have a mixedId but no database, navigate home - if (mixedId && !db) { - logger.debug('No database available, navigating home'); - navigate('/', { replace: true }); - setReady(true); - return; - } - - // If we have both mixedId and database, try to load messages - if (mixedId && db) { - try { - const storedMessages = await getMessages(db, mixedId); - if (storedMessages && storedMessages.messages.length > 0) { - setInitialMessages(storedMessages.messages); - setUrlId(storedMessages.urlId); - description.set(storedMessages.description); - chatId.set(storedMessages.id); - } else { - navigate('/', { replace: true }); - } - } catch (error) { - logger.error('Failed to load messages:', error); + const database = await initializeDb(); + + if (mixedId && database) { + const storedMessages = await getMessages(database, mixedId); + if (storedMessages && storedMessages.messages.length > 0) { + setInitialMessages(storedMessages.messages); + setUrlId(storedMessages.urlId); + description.set(storedMessages.description); + chatId.set(storedMessages.id); + } else { navigate('/', { replace: true }); } } - - setReady(true); } catch (error) { logger.error('Failed to initialize:', error); - setReady(true); } + setReady(true); }; - initDb(); + init(); }, [mixedId, navigate]); return { From 9b50acbbd6a344008b9e9a25ed9cd28d88b187cd Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:07:46 -0500 Subject: [PATCH 08/12] fix: remove persistence error toast and improve toast handling --- app/components/chat/Chat.client.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index a5d2ca786..008971819 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -54,6 +54,8 @@ export function Chat() { position="bottom-right" pauseOnFocusLoss transition={toastAnimation} + hideProgressBar + autoClose={false} /> ) : null; From 12521cb855ae77e00f14f5e9ed647c6de4670455 Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:09:00 -0500 Subject: [PATCH 09/12] fix: initialize IndexedDB early to prevent persistence errors --- app/root.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/root.tsx b/app/root.tsx index 31eb387e0..7d51ebc1f 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -50,6 +50,13 @@ const inlineThemeCode = stripIndents` document.querySelector('html')?.setAttribute('data-theme', theme); } + + // Initialize IndexedDB early + if (typeof window !== 'undefined' && window.indexedDB) { + const request = window.indexedDB.open('boltHistory', 1); + request.onerror = () => console.error('Failed to initialize IndexedDB'); + request.onsuccess = () => console.debug('IndexedDB initialized'); + } `; export const Head = createHead(() => ( From 9555816d73fd0c9ea10dcc41c578ba45f8c08a46 Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:09:21 -0500 Subject: [PATCH 10/12] fix: ensure IndexedDB is initialized before app hydration --- app/entry.client.tsx | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 62917e70d..9aa809020 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -2,6 +2,35 @@ import { RemixBrowser } from '@remix-run/react'; import { startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; -startTransition(() => { - hydrateRoot(document.getElementById('root')!, ); +// Initialize IndexedDB before hydration +async function initIndexedDB() { + if (typeof window !== 'undefined' && window.indexedDB) { + return new Promise((resolve) => { + const request = window.indexedDB.open('boltHistory', 1); + request.onerror = () => { + console.error('Failed to initialize IndexedDB'); + resolve(false); + }; + request.onsuccess = () => { + console.debug('IndexedDB initialized'); + resolve(true); + }; + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + } + }; + }); + } + return Promise.resolve(false); +} + +// Initialize IndexedDB before hydrating the app +initIndexedDB().then(() => { + startTransition(() => { + hydrateRoot(document.getElementById('root')!, ); + }); }); From 0810433f23711c5f854c7b24238e8fddf05c0945 Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:13:12 -0500 Subject: [PATCH 11/12] fix: improve database initialization and error handling --- app/lib/persistence/db.ts | 106 +++++++++++++++----------- app/lib/persistence/useChatHistory.ts | 18 ++++- 2 files changed, 77 insertions(+), 47 deletions(-) diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index 1199ff7b8..bfbb6fcd9 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -37,62 +37,78 @@ export async function openDatabase(): Promise { return; } - const request = indexedDB.open('boltHistory', 1); + // Test if we can actually open IndexedDB + const testRequest = window.indexedDB.open('test'); + testRequest.onerror = () => { + logger.error('IndexedDB test failed'); + dbInitAttempted = true; + dbInitializing = false; + resolve(undefined); + }; - request.onupgradeneeded = (event: IDBVersionChangeEvent) => { - const db = (event.target as IDBOpenDBRequest).result; - logger.debug('Upgrading database'); + testRequest.onsuccess = () => { + // Close and delete test database + const db = testRequest.result; + db.close(); + window.indexedDB.deleteDatabase('test'); - if (!db.objectStoreNames.contains('chats')) { - const store = db.createObjectStore('chats', { keyPath: 'id' }); - store.createIndex('id', 'id', { unique: true }); - store.createIndex('urlId', 'urlId', { unique: true }); - logger.debug('Created chats store'); - } - }; + // Now open the actual database + const request = window.indexedDB.open('boltHistory', 1); - request.onsuccess = (event: Event) => { - const db = (event.target as IDBOpenDBRequest).result; - logger.debug('Successfully opened database'); - - // Test if we can actually use the database - try { - const transaction = db.transaction(['chats'], 'readonly'); - transaction.oncomplete = () => { - logger.debug('Database test successful'); - dbInitAttempted = true; - dbInitializing = false; - resolve(db); - }; - transaction.onerror = () => { - logger.error('Database test failed'); + request.onupgradeneeded = (event: IDBVersionChangeEvent) => { + const db = (event.target as IDBOpenDBRequest).result; + logger.debug('Upgrading database'); + + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + logger.debug('Created chats store'); + } + }; + + request.onsuccess = (event: Event) => { + const db = (event.target as IDBOpenDBRequest).result; + logger.debug('Successfully opened database'); + + // Test if we can actually use the database + try { + const transaction = db.transaction(['chats'], 'readonly'); + transaction.oncomplete = () => { + logger.debug('Database test successful'); + dbInitAttempted = true; + dbInitializing = false; + resolve(db); + }; + transaction.onerror = () => { + logger.error('Database test failed'); + dbInitAttempted = true; + dbInitializing = false; + resolve(undefined); + }; + } catch (error) { + logger.error('Error testing database:', error); dbInitAttempted = true; dbInitializing = false; resolve(undefined); - }; - } catch (error) { - logger.error('Error testing database:', error); + } + }; + + request.onerror = (event: Event) => { + const error = (event.target as IDBOpenDBRequest).error; + logger.error('Failed to open database:', error?.message || 'Unknown error'); dbInitAttempted = true; dbInitializing = false; resolve(undefined); - } - }; + }; - request.onerror = (event: Event) => { - const error = (event.target as IDBOpenDBRequest).error; - logger.error('Failed to open database:', error?.message || 'Unknown error'); - dbInitAttempted = true; - dbInitializing = false; - resolve(undefined); - }; - - request.onblocked = () => { - logger.error('Database blocked'); - dbInitAttempted = true; - dbInitializing = false; - resolve(undefined); + request.onblocked = () => { + logger.error('Database blocked'); + dbInitAttempted = true; + dbInitializing = false; + resolve(undefined); + }; }; - } catch (error) { logger.error('Error initializing database:', error); dbInitAttempted = true; diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index eddf17509..bf0a3244f 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -31,9 +31,23 @@ async function initializeDb() { dbInitializing = true; try { + // Check if we're in a browser environment + if (typeof window === 'undefined') { + logger.debug('Not in browser environment'); + return undefined; + } + + // Check if IndexedDB is available + if (!window.indexedDB) { + logger.debug('IndexedDB not available'); + return undefined; + } + db = await openDatabase(); - dbInitialized = true; - logger.debug('Database initialized successfully'); + if (db) { + dbInitialized = true; + logger.debug('Database initialized successfully'); + } } catch (error) { logger.error('Failed to initialize database:', error); } finally { From 9cc2cde57480141afb0f13a178f88e8b9f594fde Mon Sep 17 00:00:00 2001 From: Dustin <155417613+vgcman16@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:35:08 -0500 Subject: [PATCH 12/12] feat: improve IndexedDB persistence handling - Add better browser environment detection - Add database verification and testing - Improve error handling and logging - Add proper cleanup process - Make persistence work reliably in Safari - Handle Chrome-specific issues gracefully - Add better state tracking and management Note: For the best experience with persistence features, use Safari browser. --- app/entry.client.tsx | 140 ++++++++++++++++++++++---- app/lib/persistence/useChatHistory.ts | 45 +++++++-- app/root.tsx | 85 +++++++++++++++- 3 files changed, 235 insertions(+), 35 deletions(-) diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 9aa809020..36a3d6023 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,36 +1,134 @@ import { RemixBrowser } from '@remix-run/react'; import { startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('Client'); + +function isChrome(): boolean { + return /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); +} // Initialize IndexedDB before hydration async function initIndexedDB() { - if (typeof window !== 'undefined' && window.indexedDB) { - return new Promise((resolve) => { - const request = window.indexedDB.open('boltHistory', 1); - request.onerror = () => { - console.error('Failed to initialize IndexedDB'); - resolve(false); - }; - request.onsuccess = () => { - console.debug('IndexedDB initialized'); - resolve(true); - }; - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains('chats')) { - const store = db.createObjectStore('chats', { keyPath: 'id' }); - store.createIndex('id', 'id', { unique: true }); - store.createIndex('urlId', 'urlId', { unique: true }); - } - }; - }); + if (typeof window === 'undefined' || !window.indexedDB) { + logger.debug('IndexedDB not available'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + return false; } - return Promise.resolve(false); + + return new Promise((resolve) => { + try { + // For Chrome, we need to be more careful with initialization + if (isChrome()) { + // First, try to open a test database + const testRequest = window.indexedDB.open('test', 1); + testRequest.onerror = () => { + logger.error('Test database failed'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + resolve(false); + }; + + testRequest.onsuccess = () => { + // Close and delete test database + const testDb = testRequest.result; + testDb.close(); + const deleteRequest = window.indexedDB.deleteDatabase('test'); + + deleteRequest.onsuccess = () => { + // Now try to open the actual database + const request = window.indexedDB.open('boltHistory', 1); + + request.onerror = () => { + logger.error('Failed to open database'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + resolve(false); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + } + }; + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Test if we can actually use the database + try { + const transaction = db.transaction(['chats'], 'readonly'); + transaction.oncomplete = () => { + logger.debug('Database test successful'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = true; + resolve(true); + }; + transaction.onerror = () => { + logger.error('Database test failed'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + resolve(false); + }; + } catch (error) { + logger.error('Error testing database:', error); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + resolve(false); + } + }; + }; + + deleteRequest.onerror = () => { + logger.error('Failed to delete test database'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + resolve(false); + }; + }; + } else { + // For other browsers, use the standard approach + const request = window.indexedDB.open('boltHistory', 1); + request.onerror = () => { + logger.error('Failed to open database'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + resolve(false); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + } + }; + + request.onsuccess = () => { + logger.debug('Database initialized'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = true; + resolve(true); + }; + } + } catch (error) { + logger.error('Error initializing database:', error); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + resolve(false); + } + }); } +// Set initial persistence state +window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + // Initialize IndexedDB before hydrating the app initIndexedDB().then(() => { startTransition(() => { hydrateRoot(document.getElementById('root')!, ); }); }); + +// Add type declaration +declare global { + interface Window { + __BOLT_PERSISTENCE_AVAILABLE__: boolean; + } +} diff --git a/app/lib/persistence/useChatHistory.ts b/app/lib/persistence/useChatHistory.ts index bf0a3244f..b3ac5e6cf 100644 --- a/app/lib/persistence/useChatHistory.ts +++ b/app/lib/persistence/useChatHistory.ts @@ -37,9 +37,9 @@ async function initializeDb() { return undefined; } - // Check if IndexedDB is available - if (!window.indexedDB) { - logger.debug('IndexedDB not available'); + // Check if persistence is available + if (!window.__BOLT_PERSISTENCE_AVAILABLE__) { + logger.debug('Persistence not available'); return undefined; } @@ -68,23 +68,39 @@ export function useChatHistory() { useEffect(() => { const init = async () => { try { + // Always try to initialize the database const database = await initializeDb(); + + // If we have a mixedId but no database, navigate home silently + if (mixedId && !database) { + navigate('/', { replace: true }); + setReady(true); + return; + } + // If we have both mixedId and database, try to load messages if (mixedId && database) { - const storedMessages = await getMessages(database, mixedId); - if (storedMessages && storedMessages.messages.length > 0) { - setInitialMessages(storedMessages.messages); - setUrlId(storedMessages.urlId); - description.set(storedMessages.description); - chatId.set(storedMessages.id); - } else { + try { + const storedMessages = await getMessages(database, mixedId); + if (storedMessages && storedMessages.messages.length > 0) { + setInitialMessages(storedMessages.messages); + setUrlId(storedMessages.urlId); + description.set(storedMessages.description); + chatId.set(storedMessages.id); + } else { + navigate('/', { replace: true }); + } + } catch (error) { + logger.error('Failed to load messages:', error); navigate('/', { replace: true }); } } + + setReady(true); } catch (error) { logger.error('Failed to initialize:', error); + setReady(true); } - setReady(true); }; init(); @@ -133,3 +149,10 @@ function navigateChat(nextId: string) { url.pathname = `/chat/${nextId}`; window.history.replaceState({}, '', url); } + +// Add type declaration +declare global { + interface Window { + __BOLT_PERSISTENCE_AVAILABLE__: boolean; + } +} diff --git a/app/root.tsx b/app/root.tsx index 7d51ebc1f..c653d358c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -53,9 +53,81 @@ const inlineThemeCode = stripIndents` // Initialize IndexedDB early if (typeof window !== 'undefined' && window.indexedDB) { - const request = window.indexedDB.open('boltHistory', 1); - request.onerror = () => console.error('Failed to initialize IndexedDB'); - request.onsuccess = () => console.debug('IndexedDB initialized'); + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + + // Check if we're in Chrome + const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); + + if (isChrome) { + // For Chrome, we need to be more careful with initialization + const testRequest = window.indexedDB.open('test', 1); + testRequest.onerror = () => { + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + }; + + testRequest.onsuccess = () => { + // Close and delete test database + const testDb = testRequest.result; + testDb.close(); + const deleteRequest = window.indexedDB.deleteDatabase('test'); + + deleteRequest.onsuccess = () => { + // Now try to open the actual database + const request = window.indexedDB.open('boltHistory', 1); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + } + }; + + request.onsuccess = (event) => { + const db = event.target.result; + + // Test if we can actually use the database + try { + const transaction = db.transaction(['chats'], 'readonly'); + transaction.oncomplete = () => { + window.__BOLT_PERSISTENCE_AVAILABLE__ = true; + }; + transaction.onerror = () => { + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + }; + } catch (error) { + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + } + }; + + request.onerror = () => { + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + }; + }; + + deleteRequest.onerror = () => { + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + }; + }; + } else { + // For other browsers, use the standard approach + const request = window.indexedDB.open('boltHistory', 1); + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('chats')) { + const store = db.createObjectStore('chats', { keyPath: 'id' }); + store.createIndex('id', 'id', { unique: true }); + store.createIndex('urlId', 'urlId', { unique: true }); + } + }; + request.onsuccess = () => { + window.__BOLT_PERSISTENCE_AVAILABLE__ = true; + }; + request.onerror = () => { + window.__BOLT_PERSISTENCE_AVAILABLE__ = false; + }; + } } `; @@ -88,3 +160,10 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ; } + +// Add type declaration +declare global { + interface Window { + __BOLT_PERSISTENCE_AVAILABLE__: boolean; + } +}