From 5db834e2f757767bb56275cb6782625a30d35b28 Mon Sep 17 00:00:00 2001 From: Kirjava Date: Thu, 25 Jul 2024 14:03:38 +0100 Subject: [PATCH] feat: initial persistence (#3) --- .editorconfig | 13 +++ .../bolt/app/components/chat/Chat.client.tsx | 85 ++++++++++-------- packages/bolt/app/lib/.server/login.ts | 12 +++ packages/bolt/app/lib/hooks/useSnapScroll.ts | 16 ++-- packages/bolt/app/lib/persistence/db.ts | 67 +++++++++++++++ packages/bolt/app/lib/persistence/index.ts | 2 + .../app/lib/persistence/useChatHistory.ts | 86 +++++++++++++++++++ packages/bolt/app/routes/_index.tsx | 14 +-- packages/bolt/app/routes/chat.$id.tsx | 9 ++ 9 files changed, 251 insertions(+), 53 deletions(-) create mode 100644 .editorconfig create mode 100644 packages/bolt/app/lib/persistence/db.ts create mode 100644 packages/bolt/app/lib/persistence/index.ts create mode 100644 packages/bolt/app/lib/persistence/useChatHistory.ts create mode 100644 packages/bolt/app/routes/chat.$id.tsx diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..5274ff012 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/packages/bolt/app/components/chat/Chat.client.tsx b/packages/bolt/app/components/chat/Chat.client.tsx index 346a0feba..d4ec41f14 100644 --- a/packages/bolt/app/components/chat/Chat.client.tsx +++ b/packages/bolt/app/components/chat/Chat.client.tsx @@ -1,3 +1,4 @@ +import type { Message } from 'ai'; import { useChat } from 'ai/react'; import { useAnimate } from 'framer-motion'; import { useEffect, useRef, useState } from 'react'; @@ -8,6 +9,7 @@ import { workbenchStore } from '~/lib/stores/workbench'; import { cubicEasingFn } from '~/utils/easings'; import { createScopedLogger } from '~/utils/logger'; import { BaseChat } from './BaseChat'; +import { useChatHistory } from '~/lib/persistence'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -17,9 +19,25 @@ const toastAnimation = cssTransition({ const logger = createScopedLogger('Chat'); export function Chat() { + const { ready, initialMessages, storeMessageHistory } = useChatHistory(); + + return ( + <> + {ready && } + ; + + ); +} + +interface ChatProps { + initialMessages: Message[]; + storeMessageHistory: (messages: Message[]) => Promise; +} + +export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { const textareaRef = useRef(null); - const [chatStarted, setChatStarted] = useState(false); + const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [animationScope, animate] = useAnimate(); @@ -32,6 +50,7 @@ export function Chat() { onFinish: () => { logger.debug('Finished streaming'); }, + initialMessages, }); const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); @@ -41,6 +60,7 @@ export function Chat() { useEffect(() => { parseMessages(messages, isLoading); + storeMessageHistory(messages).catch((error) => toast.error(error.message)); }, [messages, isLoading, parseMessages]); const scrollTextArea = () => { @@ -97,38 +117,35 @@ export function Chat() { const [messageRef, scrollRef] = useSnapScroll(); return ( - <> - { - if (message.role === 'user') { - return message; - } - - return { - ...message, - content: parsedMessages[i] || '', - }; - })} - enhancePrompt={() => { - enhancePrompt(input, (input) => { - setInput(input); - scrollTextArea(); - }); - }} - /> - - + { + if (message.role === 'user') { + return message; + } + + return { + ...message, + content: parsedMessages[i] || '', + }; + })} + enhancePrompt={() => { + enhancePrompt(input, (input) => { + setInput(input); + scrollTextArea(); + }); + }} + /> ); } diff --git a/packages/bolt/app/lib/.server/login.ts b/packages/bolt/app/lib/.server/login.ts index 8ea8751d6..5a501b3d2 100644 --- a/packages/bolt/app/lib/.server/login.ts +++ b/packages/bolt/app/lib/.server/login.ts @@ -1,7 +1,19 @@ import { env } from 'node:process'; +import { isAuthenticated } from './sessions'; +import { json, redirect, type LoaderFunctionArgs } from '@remix-run/cloudflare'; export function verifyPassword(password: string, cloudflareEnv: Env) { const loginPassword = env.LOGIN_PASSWORD || cloudflareEnv.LOGIN_PASSWORD; return password === loginPassword; } + +export async function handleAuthRequest({ request, context }: LoaderFunctionArgs, body: object = {}) { + const authenticated = await isAuthenticated(request, context.cloudflare.env); + + if (import.meta.env.DEV || authenticated) { + return json(body); + } + + return redirect('/login'); +} diff --git a/packages/bolt/app/lib/hooks/useSnapScroll.ts b/packages/bolt/app/lib/hooks/useSnapScroll.ts index 65e229f9b..5c1565a65 100644 --- a/packages/bolt/app/lib/hooks/useSnapScroll.ts +++ b/packages/bolt/app/lib/hooks/useSnapScroll.ts @@ -9,15 +9,13 @@ export function useSnapScroll() { const messageRef = useCallback((node: HTMLDivElement | null) => { if (node) { const observer = new ResizeObserver(() => { - if (autoScrollRef.current) { - if (scrollNodeRef.current) { - const { scrollHeight, clientHeight } = scrollNodeRef.current; - const scrollTarget = scrollHeight - clientHeight; - - scrollNodeRef.current.scrollTo({ - top: scrollTarget, - }); - } + if (autoScrollRef.current && scrollNodeRef.current) { + const { scrollHeight, clientHeight } = scrollNodeRef.current; + const scrollTarget = scrollHeight - clientHeight; + + scrollNodeRef.current.scrollTo({ + top: scrollTarget, + }); } }); diff --git a/packages/bolt/app/lib/persistence/db.ts b/packages/bolt/app/lib/persistence/db.ts new file mode 100644 index 000000000..1ead1dd86 --- /dev/null +++ b/packages/bolt/app/lib/persistence/db.ts @@ -0,0 +1,67 @@ +import type { ChatHistory } from './useChatHistory'; +import type { Message } from 'ai'; +import { createScopedLogger } from '~/utils/logger'; + +const logger = createScopedLogger('ChatHistory'); + +// this is used at the top level and never rejects +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 }); + } + }; + + request.onsuccess = (event: Event) => { + resolve((event.target as IDBOpenDBRequest).result); + }; + + request.onerror = (event: Event) => { + resolve(undefined); + logger.error((event.target as IDBOpenDBRequest).error); + }; + }); +} + +export async function setMessages(db: IDBDatabase, id: string, messages: Message[]): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction('chats', 'readwrite'); + const store = transaction.objectStore('chats'); + + const request = store.put({ + id, + messages, + }); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +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 request = store.get(id); + + request.onsuccess = () => resolve(request.result as ChatHistory); + request.onerror = () => reject(request.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.count(); + + request.onsuccess = () => resolve(String(request.result)); + request.onerror = () => reject(request.error); + }); +} diff --git a/packages/bolt/app/lib/persistence/index.ts b/packages/bolt/app/lib/persistence/index.ts new file mode 100644 index 000000000..4d0205830 --- /dev/null +++ b/packages/bolt/app/lib/persistence/index.ts @@ -0,0 +1,2 @@ +export * from './db'; +export * from './useChatHistory'; diff --git a/packages/bolt/app/lib/persistence/useChatHistory.ts b/packages/bolt/app/lib/persistence/useChatHistory.ts new file mode 100644 index 000000000..98671e392 --- /dev/null +++ b/packages/bolt/app/lib/persistence/useChatHistory.ts @@ -0,0 +1,86 @@ +import { useNavigate, useLoaderData } from '@remix-run/react'; +import { useState, useEffect } from 'react'; +import type { Message } from 'ai'; +import { openDatabase, setMessages, getMessages, getNextID } from './db'; +import { toast } from 'react-toastify'; + +export interface ChatHistory { + id: string; + displayName?: string; + messages: Message[]; +} + +const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE; + +const db = persistenceEnabled ? await openDatabase() : undefined; + +export function useChatHistory() { + const navigate = useNavigate(); + const { id: chatId } = useLoaderData<{ id?: string }>(); + + const [initialMessages, setInitialMessages] = useState([]); + const [ready, setReady] = useState(false); + const [entryId, setEntryId] = useState(); + + useEffect(() => { + if (!db) { + setReady(true); + + if (persistenceEnabled) { + toast.error(`Chat persistence is unavailable`); + } + + return; + } + + if (chatId) { + getMessages(db, chatId) + .then((storedMessages) => { + if (storedMessages && storedMessages.messages.length > 0) { + setInitialMessages(storedMessages.messages); + } else { + navigate(`/`, { replace: true }); + } + + setReady(true); + }) + .catch((error) => { + toast.error(error.message); + }); + } + }, []); + + return { + ready: !chatId || ready, + initialMessages, + storeMessageHistory: async (messages: Message[]) => { + if (!db || messages.length === 0) { + return; + } + + if (initialMessages.length === 0) { + if (!entryId) { + const nextId = await getNextID(db); + + await setMessages(db, nextId, messages); + + setEntryId(nextId); + + /** + * 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); + } else { + await setMessages(db, entryId, messages); + } + } else { + await setMessages(db, chatId as string, messages); + } + }, + }; +} diff --git a/packages/bolt/app/routes/_index.tsx b/packages/bolt/app/routes/_index.tsx index e700e0422..62aabc9da 100644 --- a/packages/bolt/app/routes/_index.tsx +++ b/packages/bolt/app/routes/_index.tsx @@ -1,22 +1,16 @@ -import { json, redirect, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflare'; +import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflare'; import { ClientOnly } from 'remix-utils/client-only'; import { BaseChat } from '~/components/chat/BaseChat'; import { Chat } from '~/components/chat/Chat.client'; import { Header } from '~/components/Header'; -import { isAuthenticated } from '~/lib/.server/sessions'; +import { handleAuthRequest } from '~/lib/.server/login'; export const meta: MetaFunction = () => { return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }]; }; -export async function loader({ request, context }: LoaderFunctionArgs) { - const authenticated = await isAuthenticated(request, context.cloudflare.env); - - if (import.meta.env.DEV || authenticated) { - return json({}); - } - - return redirect('/login'); +export async function loader(args: LoaderFunctionArgs) { + return handleAuthRequest(args); } export default function Index() { diff --git a/packages/bolt/app/routes/chat.$id.tsx b/packages/bolt/app/routes/chat.$id.tsx new file mode 100644 index 000000000..e0ed0af9f --- /dev/null +++ b/packages/bolt/app/routes/chat.$id.tsx @@ -0,0 +1,9 @@ +import type { LoaderFunctionArgs } from '@remix-run/cloudflare'; +import { default as IndexRoute } from './_index'; +import { handleAuthRequest } from '~/lib/.server/login'; + +export async function loader(args: LoaderFunctionArgs) { + return handleAuthRequest(args, { id: args.params.id }); +} + +export default IndexRoute;