Kirjava commited on
Commit
5db834e
·
unverified ·
1 Parent(s): 2b1bf0f

feat: initial persistence (#3)

Browse files
.editorconfig ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ indent_style = space
5
+ end_of_line = lf
6
+ charset = utf-8
7
+ trim_trailing_whitespace = true
8
+ insert_final_newline = true
9
+ max_line_length = 120
10
+ indent_size = 2
11
+
12
+ [*.md]
13
+ trim_trailing_whitespace = false
packages/bolt/app/components/chat/Chat.client.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import { useChat } from 'ai/react';
2
  import { useAnimate } from 'framer-motion';
3
  import { useEffect, useRef, useState } from 'react';
@@ -8,6 +9,7 @@ import { workbenchStore } from '~/lib/stores/workbench';
8
  import { cubicEasingFn } from '~/utils/easings';
9
  import { createScopedLogger } from '~/utils/logger';
10
  import { BaseChat } from './BaseChat';
 
11
 
12
  const toastAnimation = cssTransition({
13
  enter: 'animated fadeInRight',
@@ -17,9 +19,25 @@ const toastAnimation = cssTransition({
17
  const logger = createScopedLogger('Chat');
18
 
19
  export function Chat() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  const textareaRef = useRef<HTMLTextAreaElement>(null);
21
 
22
- const [chatStarted, setChatStarted] = useState(false);
23
 
24
  const [animationScope, animate] = useAnimate();
25
 
@@ -32,6 +50,7 @@ export function Chat() {
32
  onFinish: () => {
33
  logger.debug('Finished streaming');
34
  },
 
35
  });
36
 
37
  const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
@@ -41,6 +60,7 @@ export function Chat() {
41
 
42
  useEffect(() => {
43
  parseMessages(messages, isLoading);
 
44
  }, [messages, isLoading, parseMessages]);
45
 
46
  const scrollTextArea = () => {
@@ -97,38 +117,35 @@ export function Chat() {
97
  const [messageRef, scrollRef] = useSnapScroll();
98
 
99
  return (
100
- <>
101
- <BaseChat
102
- ref={animationScope}
103
- textareaRef={textareaRef}
104
- input={input}
105
- chatStarted={chatStarted}
106
- isStreaming={isLoading}
107
- enhancingPrompt={enhancingPrompt}
108
- promptEnhanced={promptEnhanced}
109
- sendMessage={sendMessage}
110
- messageRef={messageRef}
111
- scrollRef={scrollRef}
112
- handleInputChange={handleInputChange}
113
- handleStop={abort}
114
- messages={messages.map((message, i) => {
115
- if (message.role === 'user') {
116
- return message;
117
- }
118
-
119
- return {
120
- ...message,
121
- content: parsedMessages[i] || '',
122
- };
123
- })}
124
- enhancePrompt={() => {
125
- enhancePrompt(input, (input) => {
126
- setInput(input);
127
- scrollTextArea();
128
- });
129
- }}
130
- />
131
- <ToastContainer position="bottom-right" stacked={true} pauseOnFocusLoss={true} transition={toastAnimation} />
132
- </>
133
  );
134
  }
 
1
+ import type { Message } from 'ai';
2
  import { useChat } from 'ai/react';
3
  import { useAnimate } from 'framer-motion';
4
  import { useEffect, useRef, useState } from 'react';
 
9
  import { cubicEasingFn } from '~/utils/easings';
10
  import { createScopedLogger } from '~/utils/logger';
11
  import { BaseChat } from './BaseChat';
12
+ import { useChatHistory } from '~/lib/persistence';
13
 
14
  const toastAnimation = cssTransition({
15
  enter: 'animated fadeInRight',
 
19
  const logger = createScopedLogger('Chat');
20
 
21
  export function Chat() {
22
+ const { ready, initialMessages, storeMessageHistory } = useChatHistory();
23
+
24
+ return (
25
+ <>
26
+ {ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
27
+ <ToastContainer position="bottom-right" stacked pauseOnFocusLoss transition={toastAnimation} />;
28
+ </>
29
+ );
30
+ }
31
+
32
+ interface ChatProps {
33
+ initialMessages: Message[];
34
+ storeMessageHistory: (messages: Message[]) => Promise<void>;
35
+ }
36
+
37
+ export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) {
38
  const textareaRef = useRef<HTMLTextAreaElement>(null);
39
 
40
+ const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
41
 
42
  const [animationScope, animate] = useAnimate();
43
 
 
50
  onFinish: () => {
51
  logger.debug('Finished streaming');
52
  },
53
+ initialMessages,
54
  });
55
 
56
  const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
 
60
 
61
  useEffect(() => {
62
  parseMessages(messages, isLoading);
63
+ storeMessageHistory(messages).catch((error) => toast.error(error.message));
64
  }, [messages, isLoading, parseMessages]);
65
 
66
  const scrollTextArea = () => {
 
117
  const [messageRef, scrollRef] = useSnapScroll();
118
 
119
  return (
120
+ <BaseChat
121
+ ref={animationScope}
122
+ textareaRef={textareaRef}
123
+ input={input}
124
+ chatStarted={chatStarted}
125
+ isStreaming={isLoading}
126
+ enhancingPrompt={enhancingPrompt}
127
+ promptEnhanced={promptEnhanced}
128
+ sendMessage={sendMessage}
129
+ messageRef={messageRef}
130
+ scrollRef={scrollRef}
131
+ handleInputChange={handleInputChange}
132
+ handleStop={abort}
133
+ messages={messages.map((message, i) => {
134
+ if (message.role === 'user') {
135
+ return message;
136
+ }
137
+
138
+ return {
139
+ ...message,
140
+ content: parsedMessages[i] || '',
141
+ };
142
+ })}
143
+ enhancePrompt={() => {
144
+ enhancePrompt(input, (input) => {
145
+ setInput(input);
146
+ scrollTextArea();
147
+ });
148
+ }}
149
+ />
 
 
 
150
  );
151
  }
packages/bolt/app/lib/.server/login.ts CHANGED
@@ -1,7 +1,19 @@
1
  import { env } from 'node:process';
 
 
2
 
3
  export function verifyPassword(password: string, cloudflareEnv: Env) {
4
  const loginPassword = env.LOGIN_PASSWORD || cloudflareEnv.LOGIN_PASSWORD;
5
 
6
  return password === loginPassword;
7
  }
 
 
 
 
 
 
 
 
 
 
 
1
  import { env } from 'node:process';
2
+ import { isAuthenticated } from './sessions';
3
+ import { json, redirect, type LoaderFunctionArgs } from '@remix-run/cloudflare';
4
 
5
  export function verifyPassword(password: string, cloudflareEnv: Env) {
6
  const loginPassword = env.LOGIN_PASSWORD || cloudflareEnv.LOGIN_PASSWORD;
7
 
8
  return password === loginPassword;
9
  }
10
+
11
+ export async function handleAuthRequest({ request, context }: LoaderFunctionArgs, body: object = {}) {
12
+ const authenticated = await isAuthenticated(request, context.cloudflare.env);
13
+
14
+ if (import.meta.env.DEV || authenticated) {
15
+ return json(body);
16
+ }
17
+
18
+ return redirect('/login');
19
+ }
packages/bolt/app/lib/hooks/useSnapScroll.ts CHANGED
@@ -9,15 +9,13 @@ export function useSnapScroll() {
9
  const messageRef = useCallback((node: HTMLDivElement | null) => {
10
  if (node) {
11
  const observer = new ResizeObserver(() => {
12
- if (autoScrollRef.current) {
13
- if (scrollNodeRef.current) {
14
- const { scrollHeight, clientHeight } = scrollNodeRef.current;
15
- const scrollTarget = scrollHeight - clientHeight;
16
-
17
- scrollNodeRef.current.scrollTo({
18
- top: scrollTarget,
19
- });
20
- }
21
  }
22
  });
23
 
 
9
  const messageRef = useCallback((node: HTMLDivElement | null) => {
10
  if (node) {
11
  const observer = new ResizeObserver(() => {
12
+ if (autoScrollRef.current && scrollNodeRef.current) {
13
+ const { scrollHeight, clientHeight } = scrollNodeRef.current;
14
+ const scrollTarget = scrollHeight - clientHeight;
15
+
16
+ scrollNodeRef.current.scrollTo({
17
+ top: scrollTarget,
18
+ });
 
 
19
  }
20
  });
21
 
packages/bolt/app/lib/persistence/db.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ChatHistory } from './useChatHistory';
2
+ import type { Message } from 'ai';
3
+ import { createScopedLogger } from '~/utils/logger';
4
+
5
+ const logger = createScopedLogger('ChatHistory');
6
+
7
+ // this is used at the top level and never rejects
8
+ export async function openDatabase(): Promise<IDBDatabase | undefined> {
9
+ return new Promise((resolve) => {
10
+ const request = indexedDB.open('boltHistory', 1);
11
+
12
+ request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
13
+ const db = (event.target as IDBOpenDBRequest).result;
14
+
15
+ if (!db.objectStoreNames.contains('chats')) {
16
+ const store = db.createObjectStore('chats', { keyPath: 'id' });
17
+ store.createIndex('id', 'id', { unique: true });
18
+ }
19
+ };
20
+
21
+ request.onsuccess = (event: Event) => {
22
+ resolve((event.target as IDBOpenDBRequest).result);
23
+ };
24
+
25
+ request.onerror = (event: Event) => {
26
+ resolve(undefined);
27
+ logger.error((event.target as IDBOpenDBRequest).error);
28
+ };
29
+ });
30
+ }
31
+
32
+ export async function setMessages(db: IDBDatabase, id: string, messages: Message[]): Promise<void> {
33
+ return new Promise((resolve, reject) => {
34
+ const transaction = db.transaction('chats', 'readwrite');
35
+ const store = transaction.objectStore('chats');
36
+
37
+ const request = store.put({
38
+ id,
39
+ messages,
40
+ });
41
+
42
+ request.onsuccess = () => resolve();
43
+ request.onerror = () => reject(request.error);
44
+ });
45
+ }
46
+
47
+ export async function getMessages(db: IDBDatabase, id: string): Promise<ChatHistory> {
48
+ return new Promise((resolve, reject) => {
49
+ const transaction = db.transaction('chats', 'readonly');
50
+ const store = transaction.objectStore('chats');
51
+ const request = store.get(id);
52
+
53
+ request.onsuccess = () => resolve(request.result as ChatHistory);
54
+ request.onerror = () => reject(request.error);
55
+ });
56
+ }
57
+
58
+ export async function getNextID(db: IDBDatabase): Promise<string> {
59
+ return new Promise((resolve, reject) => {
60
+ const transaction = db.transaction('chats', 'readonly');
61
+ const store = transaction.objectStore('chats');
62
+ const request = store.count();
63
+
64
+ request.onsuccess = () => resolve(String(request.result));
65
+ request.onerror = () => reject(request.error);
66
+ });
67
+ }
packages/bolt/app/lib/persistence/index.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ export * from './db';
2
+ export * from './useChatHistory';
packages/bolt/app/lib/persistence/useChatHistory.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useNavigate, useLoaderData } from '@remix-run/react';
2
+ import { useState, useEffect } from 'react';
3
+ import type { Message } from 'ai';
4
+ import { openDatabase, setMessages, getMessages, getNextID } from './db';
5
+ import { toast } from 'react-toastify';
6
+
7
+ export interface ChatHistory {
8
+ id: string;
9
+ displayName?: string;
10
+ messages: Message[];
11
+ }
12
+
13
+ const persistenceEnabled = !import.meta.env.VITE_DISABLE_PERSISTENCE;
14
+
15
+ const db = persistenceEnabled ? await openDatabase() : undefined;
16
+
17
+ export function useChatHistory() {
18
+ const navigate = useNavigate();
19
+ const { id: chatId } = useLoaderData<{ id?: string }>();
20
+
21
+ const [initialMessages, setInitialMessages] = useState<Message[]>([]);
22
+ const [ready, setReady] = useState<boolean>(false);
23
+ const [entryId, setEntryId] = useState<string | undefined>();
24
+
25
+ useEffect(() => {
26
+ if (!db) {
27
+ setReady(true);
28
+
29
+ if (persistenceEnabled) {
30
+ toast.error(`Chat persistence is unavailable`);
31
+ }
32
+
33
+ return;
34
+ }
35
+
36
+ if (chatId) {
37
+ getMessages(db, chatId)
38
+ .then((storedMessages) => {
39
+ if (storedMessages && storedMessages.messages.length > 0) {
40
+ setInitialMessages(storedMessages.messages);
41
+ } else {
42
+ navigate(`/`, { replace: true });
43
+ }
44
+
45
+ setReady(true);
46
+ })
47
+ .catch((error) => {
48
+ toast.error(error.message);
49
+ });
50
+ }
51
+ }, []);
52
+
53
+ return {
54
+ ready: !chatId || ready,
55
+ initialMessages,
56
+ storeMessageHistory: async (messages: Message[]) => {
57
+ if (!db || messages.length === 0) {
58
+ return;
59
+ }
60
+
61
+ if (initialMessages.length === 0) {
62
+ if (!entryId) {
63
+ const nextId = await getNextID(db);
64
+
65
+ await setMessages(db, nextId, messages);
66
+
67
+ setEntryId(nextId);
68
+
69
+ /**
70
+ * FIXME: Using the intended navigate function causes a rerender for <Chat /> that breaks the app.
71
+ *
72
+ * `navigate(`/chat/${nextId}`, { replace: true });`
73
+ */
74
+ const url = new URL(window.location.href);
75
+ url.pathname = `/chat/${nextId}`;
76
+
77
+ window.history.replaceState({}, '', url);
78
+ } else {
79
+ await setMessages(db, entryId, messages);
80
+ }
81
+ } else {
82
+ await setMessages(db, chatId as string, messages);
83
+ }
84
+ },
85
+ };
86
+ }
packages/bolt/app/routes/_index.tsx CHANGED
@@ -1,22 +1,16 @@
1
- import { json, redirect, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflare';
2
  import { ClientOnly } from 'remix-utils/client-only';
3
  import { BaseChat } from '~/components/chat/BaseChat';
4
  import { Chat } from '~/components/chat/Chat.client';
5
  import { Header } from '~/components/Header';
6
- import { isAuthenticated } from '~/lib/.server/sessions';
7
 
8
  export const meta: MetaFunction = () => {
9
  return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
10
  };
11
 
12
- export async function loader({ request, context }: LoaderFunctionArgs) {
13
- const authenticated = await isAuthenticated(request, context.cloudflare.env);
14
-
15
- if (import.meta.env.DEV || authenticated) {
16
- return json({});
17
- }
18
-
19
- return redirect('/login');
20
  }
21
 
22
  export default function Index() {
 
1
+ import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/cloudflare';
2
  import { ClientOnly } from 'remix-utils/client-only';
3
  import { BaseChat } from '~/components/chat/BaseChat';
4
  import { Chat } from '~/components/chat/Chat.client';
5
  import { Header } from '~/components/Header';
6
+ import { handleAuthRequest } from '~/lib/.server/login';
7
 
8
  export const meta: MetaFunction = () => {
9
  return [{ title: 'Bolt' }, { name: 'description', content: 'Talk with Bolt, an AI assistant from StackBlitz' }];
10
  };
11
 
12
+ export async function loader(args: LoaderFunctionArgs) {
13
+ return handleAuthRequest(args);
 
 
 
 
 
 
14
  }
15
 
16
  export default function Index() {
packages/bolt/app/routes/chat.$id.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
2
+ import { default as IndexRoute } from './_index';
3
+ import { handleAuthRequest } from '~/lib/.server/login';
4
+
5
+ export async function loader(args: LoaderFunctionArgs) {
6
+ return handleAuthRequest(args, { id: args.params.id });
7
+ }
8
+
9
+ export default IndexRoute;