Eduards commited on
Commit
6e8aa04
·
1 Parent(s): f6a7c4f

Lint fixes

Browse files
app/components/chat/BaseChat.tsx CHANGED
@@ -19,7 +19,6 @@ import * as Tooltip from '@radix-ui/react-tooltip';
19
 
20
  import styles from './BaseChat.module.scss';
21
  import type { ProviderInfo } from '~/utils/types';
22
- import WithTooltip from '~/components/ui/Tooltip';
23
  import { ExportChatButton } from '~/components/chat/ExportChatButton';
24
 
25
  const EXAMPLE_PROMPTS = [
@@ -27,7 +26,7 @@ const EXAMPLE_PROMPTS = [
27
  { text: 'Build a simple blog using Astro' },
28
  { text: 'Create a cookie consent form using Material UI' },
29
  { text: 'Make a space invaders game' },
30
- { text: 'How do I center a div?' }
31
  ];
32
 
33
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -110,7 +109,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
110
  enhancingPrompt = false,
111
  promptEnhanced = false,
112
  messages,
113
- description,
114
  input = '',
115
  model,
116
  setModel,
@@ -121,9 +119,9 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
121
  enhancePrompt,
122
  handleStop,
123
  importChat,
124
- exportChat
125
  },
126
- ref
127
  ) => {
128
  const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
129
  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
@@ -163,7 +161,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
163
  expires: 30, // 30 days
164
  secure: true, // Only send over HTTPS
165
  sameSite: 'strict', // Protect against CSRF
166
- path: '/' // Accessible across the site
167
  });
168
  } catch (error) {
169
  console.error('Error saving API keys to cookies:', error);
@@ -176,7 +174,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
176
  ref={ref}
177
  className={classNames(
178
  styles.BaseChat,
179
- 'relative flex flex-col lg:flex-row h-full w-full overflow-hidden bg-bolt-elements-background-depth-1'
180
  )}
181
  data-chat-visible={showChat}
182
  >
@@ -195,7 +193,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
195
  )}
196
  <div
197
  className={classNames('pt-6 px-2 sm:px-6', {
198
- 'h-full flex flex-col': chatStarted
199
  })}
200
  >
201
  <ClientOnly>
@@ -215,8 +213,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
215
  'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt mb-6',
216
  {
217
  'sticky bottom-2': chatStarted,
218
- },
219
- )}
220
  >
221
  <ModelSelector
222
  key={provider?.name + ':' + modelList.length}
@@ -226,45 +224,46 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
226
  provider={provider}
227
  setProvider={setProvider}
228
  providerList={PROVIDER_LIST}
229
- apiKeys={apiKeys}
230
- />
231
  {provider && (
232
  <APIKeyManager
233
  provider={provider}
234
  apiKey={apiKeys[provider.name] || ''}
235
- setApiKey={(key) => updateApiKey(provider.name, key)}/>
236
- )}
 
237
 
238
  <div
239
  className={classNames(
240
- 'shadow-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden transition-all'
241
  )}
242
  >
243
- <textarea
244
- ref={textareaRef}
245
- className={`w-full pl-4 pt-4 pr-16 focus:outline-none focus:ring-0 focus:border-none focus:shadow-none resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent transition-all`}
246
- onKeyDown={(event) => {
247
- if (event.key === 'Enter') {
248
- if (event.shiftKey) {
249
- return;
250
- }
251
 
252
- event.preventDefault();
253
 
254
- sendMessage?.(event);
255
- }
256
- }}
257
- value={input}
258
- onChange={(event) => {
259
- handleInputChange?.(event);
260
- }}
261
- style={{
262
- minHeight: TEXTAREA_MIN_HEIGHT,
263
- maxHeight: TEXTAREA_MAX_HEIGHT
264
- }}
265
- placeholder="How can Bolt help you today?"
266
- translate="no"
267
- />
268
  <ClientOnly>
269
  {() => (
270
  <SendButton
@@ -289,14 +288,13 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
289
  className={classNames('transition-all', {
290
  'opacity-100!': enhancingPrompt,
291
  'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
292
- promptEnhanced
293
  })}
294
  onClick={() => enhancePrompt?.()}
295
  >
296
  {enhancingPrompt ? (
297
  <>
298
- <div
299
- className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
300
  <div className="ml-1.5">Enhancing prompt...</div>
301
  </>
302
  ) : (
@@ -306,15 +304,13 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
306
  </>
307
  )}
308
  </IconButton>
309
- <ClientOnly>{() => <ExportChatButton exportChat={exportChat}/>}</ClientOnly>
310
  </div>
311
  {input.length > 3 ? (
312
  <div className="text-xs text-bolt-elements-textTertiary">
313
- Use <kbd
314
- className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
315
- <kbd
316
- className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> for
317
- a new line
318
  </div>
319
  ) : null}
320
  </div>
@@ -331,25 +327,28 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
331
  accept=".json"
332
  onChange={async (e) => {
333
  const file = e.target.files?.[0];
 
334
  if (file && importChat) {
335
  try {
336
  const reader = new FileReader();
 
337
  reader.onload = async (e) => {
338
  try {
339
  const content = e.target?.result as string;
340
  const data = JSON.parse(content);
 
341
  if (!Array.isArray(data.messages)) {
342
  toast.error('Invalid chat file format');
343
  }
 
344
  await importChat(data.description, data.messages);
345
  toast.success('Chat imported successfully');
346
  } catch (error) {
347
- toast.error('Failed to parse chat file');
348
  }
349
  };
350
  reader.onerror = () => toast.error('Failed to read chat file');
351
  reader.readAsText(file);
352
-
353
  } catch (error) {
354
  toast.error(error instanceof Error ? error.message : 'Failed to import chat');
355
  }
@@ -377,8 +376,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
377
  )}
378
  {!chatStarted && (
379
  <div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
380
- <div
381
- className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
382
  {EXAMPLE_PROMPTS.map((examplePrompt, index) => {
383
  return (
384
  <button
@@ -402,5 +400,5 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
402
  </div>
403
  </Tooltip.Provider>
404
  );
405
- }
406
  );
 
19
 
20
  import styles from './BaseChat.module.scss';
21
  import type { ProviderInfo } from '~/utils/types';
 
22
  import { ExportChatButton } from '~/components/chat/ExportChatButton';
23
 
24
  const EXAMPLE_PROMPTS = [
 
26
  { text: 'Build a simple blog using Astro' },
27
  { text: 'Create a cookie consent form using Material UI' },
28
  { text: 'Make a space invaders game' },
29
+ { text: 'How do I center a div?' },
30
  ];
31
 
32
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
 
109
  enhancingPrompt = false,
110
  promptEnhanced = false,
111
  messages,
 
112
  input = '',
113
  model,
114
  setModel,
 
119
  enhancePrompt,
120
  handleStop,
121
  importChat,
122
+ exportChat,
123
  },
124
+ ref,
125
  ) => {
126
  const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
127
  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
 
161
  expires: 30, // 30 days
162
  secure: true, // Only send over HTTPS
163
  sameSite: 'strict', // Protect against CSRF
164
+ path: '/', // Accessible across the site
165
  });
166
  } catch (error) {
167
  console.error('Error saving API keys to cookies:', error);
 
174
  ref={ref}
175
  className={classNames(
176
  styles.BaseChat,
177
+ 'relative flex flex-col lg:flex-row h-full w-full overflow-hidden bg-bolt-elements-background-depth-1',
178
  )}
179
  data-chat-visible={showChat}
180
  >
 
193
  )}
194
  <div
195
  className={classNames('pt-6 px-2 sm:px-6', {
196
+ 'h-full flex flex-col': chatStarted,
197
  })}
198
  >
199
  <ClientOnly>
 
213
  'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt mb-6',
214
  {
215
  'sticky bottom-2': chatStarted,
216
+ },
217
+ )}
218
  >
219
  <ModelSelector
220
  key={provider?.name + ':' + modelList.length}
 
224
  provider={provider}
225
  setProvider={setProvider}
226
  providerList={PROVIDER_LIST}
227
+ apiKeys={apiKeys}
228
+ />
229
  {provider && (
230
  <APIKeyManager
231
  provider={provider}
232
  apiKey={apiKeys[provider.name] || ''}
233
+ setApiKey={(key) => updateApiKey(provider.name, key)}
234
+ />
235
+ )}
236
 
237
  <div
238
  className={classNames(
239
+ 'shadow-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background backdrop-filter backdrop-blur-[8px] rounded-lg overflow-hidden transition-all',
240
  )}
241
  >
242
+ <textarea
243
+ ref={textareaRef}
244
+ className={`w-full pl-4 pt-4 pr-16 focus:outline-none focus:ring-0 focus:border-none focus:shadow-none resize-none text-md text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent transition-all`}
245
+ onKeyDown={(event) => {
246
+ if (event.key === 'Enter') {
247
+ if (event.shiftKey) {
248
+ return;
249
+ }
250
 
251
+ event.preventDefault();
252
 
253
+ sendMessage?.(event);
254
+ }
255
+ }}
256
+ value={input}
257
+ onChange={(event) => {
258
+ handleInputChange?.(event);
259
+ }}
260
+ style={{
261
+ minHeight: TEXTAREA_MIN_HEIGHT,
262
+ maxHeight: TEXTAREA_MAX_HEIGHT,
263
+ }}
264
+ placeholder="How can Bolt help you today?"
265
+ translate="no"
266
+ />
267
  <ClientOnly>
268
  {() => (
269
  <SendButton
 
288
  className={classNames('transition-all', {
289
  'opacity-100!': enhancingPrompt,
290
  'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
291
+ promptEnhanced,
292
  })}
293
  onClick={() => enhancePrompt?.()}
294
  >
295
  {enhancingPrompt ? (
296
  <>
297
+ <div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
 
298
  <div className="ml-1.5">Enhancing prompt...</div>
299
  </>
300
  ) : (
 
304
  </>
305
  )}
306
  </IconButton>
307
+ <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>
308
  </div>
309
  {input.length > 3 ? (
310
  <div className="text-xs text-bolt-elements-textTertiary">
311
+ Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd>{' '}
312
+ + <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd>{' '}
313
+ for a new line
 
 
314
  </div>
315
  ) : null}
316
  </div>
 
327
  accept=".json"
328
  onChange={async (e) => {
329
  const file = e.target.files?.[0];
330
+
331
  if (file && importChat) {
332
  try {
333
  const reader = new FileReader();
334
+
335
  reader.onload = async (e) => {
336
  try {
337
  const content = e.target?.result as string;
338
  const data = JSON.parse(content);
339
+
340
  if (!Array.isArray(data.messages)) {
341
  toast.error('Invalid chat file format');
342
  }
343
+
344
  await importChat(data.description, data.messages);
345
  toast.success('Chat imported successfully');
346
  } catch (error) {
347
+ toast.error('Failed to parse chat file: ' + error.message);
348
  }
349
  };
350
  reader.onerror = () => toast.error('Failed to read chat file');
351
  reader.readAsText(file);
 
352
  } catch (error) {
353
  toast.error(error instanceof Error ? error.message : 'Failed to import chat');
354
  }
 
376
  )}
377
  {!chatStarted && (
378
  <div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
379
+ <div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
 
380
  {EXAMPLE_PROMPTS.map((examplePrompt, index) => {
381
  return (
382
  <button
 
400
  </div>
401
  </Tooltip.Provider>
402
  );
403
+ },
404
  );
app/components/chat/Chat.client.tsx CHANGED
@@ -35,7 +35,15 @@ export function Chat() {
35
 
36
  return (
37
  <>
38
- {ready && <ChatImpl description={title} initialMessages={initialMessages} exportChat={exportChat} storeMessageHistory={storeMessageHistory} importChat={importChat} />}
 
 
 
 
 
 
 
 
39
  <ToastContainer
40
  closeButton={({ closeToast }) => {
41
  return (
@@ -74,217 +82,219 @@ interface ChatProps {
74
  exportChat: () => void;
75
  }
76
 
77
- export const ChatImpl = memo(({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
78
- useShortcuts();
79
-
80
- const textareaRef = useRef<HTMLTextAreaElement>(null);
81
-
82
- const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
83
- const [model, setModel] = useState(() => {
84
- const savedModel = Cookies.get('selectedModel');
85
- return savedModel || DEFAULT_MODEL;
86
- });
87
- const [provider, setProvider] = useState(() => {
88
- const savedProvider = Cookies.get('selectedProvider');
89
- return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
90
- });
91
-
92
- const { showChat } = useStore(chatStore);
93
-
94
- const [animationScope, animate] = useAnimate();
95
-
96
- const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
97
-
98
- const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
99
- api: '/api/chat',
100
- body: {
101
- apiKeys,
102
- },
103
- onError: (error) => {
104
- logger.error('Request failed\n\n', error);
105
- toast.error(
106
- 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
107
- );
108
- },
109
- onFinish: () => {
110
- logger.debug('Finished streaming');
111
- },
112
- initialMessages,
113
- });
114
-
115
- const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
116
- const { parsedMessages, parseMessages } = useMessageParser();
117
-
118
- const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
119
-
120
- useEffect(() => {
121
- chatStore.setKey('started', initialMessages.length > 0);
122
- }, []);
123
-
124
- useEffect(() => {
125
- parseMessages(messages, isLoading);
126
-
127
- if (messages.length > initialMessages.length) {
128
- storeMessageHistory(messages).catch((error) => toast.error(error.message));
129
- }
130
- }, [messages, isLoading, parseMessages]);
131
 
132
- const scrollTextArea = () => {
133
- const textarea = textareaRef.current;
134
 
135
- if (textarea) {
136
- textarea.scrollTop = textarea.scrollHeight;
137
- }
138
- };
139
 
140
- const abort = () => {
141
- stop();
142
- chatStore.setKey('aborted', true);
143
- workbenchStore.abortAllActions();
144
- };
145
 
146
- useEffect(() => {
147
- const textarea = textareaRef.current;
148
 
149
- if (textarea) {
150
- textarea.style.height = 'auto';
 
 
151
 
152
- const scrollHeight = textarea.scrollHeight;
 
153
 
154
- textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
155
- textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
156
- }
157
- }, [input, textareaRef]);
158
 
159
- const runAnimation = async () => {
160
- if (chatStarted) {
161
- return;
162
- }
 
163
 
164
- await Promise.all([
165
- animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
166
- animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
167
- ]);
168
 
169
- chatStore.setKey('started', true);
 
170
 
171
- setChatStarted(true);
172
- };
173
 
174
- const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
175
- const _input = messageInput || input;
 
 
176
 
177
- if (_input.length === 0 || isLoading) {
178
- return;
179
- }
 
180
 
181
- /**
182
- * @note (delm) Usually saving files shouldn't take long but it may take longer if there
183
- * many unsaved files. In that case we need to block user input and show an indicator
184
- * of some kind so the user is aware that something is happening. But I consider the
185
- * happy case to be no unsaved files and I would expect users to save their changes
186
- * before they send another message.
187
- */
188
- await workbenchStore.saveAllFiles();
189
 
190
- const fileModifications = workbenchStore.getFileModifcations();
191
 
192
- chatStore.setKey('aborted', false);
 
193
 
194
- runAnimation();
 
195
 
196
- if (fileModifications !== undefined) {
197
- const diff = fileModificationsToHTML(fileModifications);
 
198
 
199
  /**
200
- * If we have file modifications we append a new user message manually since we have to prefix
201
- * the user input with the file modifications and we don't want the new user input to appear
202
- * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
203
- * manually reset the input and we'd have to manually pass in file attachments. However, those
204
- * aren't relevant here.
205
  */
206
- append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
207
-
208
- /**
209
- * After sending a new message we reset all modifications since the model
210
- * should now be aware of all the changes.
211
- */
212
- workbenchStore.resetAllFileModifications();
213
- } else {
214
- append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
215
- }
216
-
217
- setInput('');
218
-
219
- resetEnhancer();
220
-
221
- textareaRef.current?.blur();
222
- };
223
-
224
- const [messageRef, scrollRef] = useSnapScroll();
225
-
226
- useEffect(() => {
227
- const storedApiKeys = Cookies.get('apiKeys');
228
-
229
- if (storedApiKeys) {
230
- setApiKeys(JSON.parse(storedApiKeys));
231
- }
232
- }, []);
233
-
234
- const handleModelChange = (newModel: string) => {
235
- setModel(newModel);
236
- Cookies.set('selectedModel', newModel, { expires: 30 });
237
- };
238
-
239
- const handleProviderChange = (newProvider: ProviderInfo) => {
240
- setProvider(newProvider);
241
- Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
242
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
- return (
245
- <BaseChat
246
- ref={animationScope}
247
- textareaRef={textareaRef}
248
- input={input}
249
- showChat={showChat}
250
- chatStarted={chatStarted}
251
- isStreaming={isLoading}
252
- enhancingPrompt={enhancingPrompt}
253
- promptEnhanced={promptEnhanced}
254
- sendMessage={sendMessage}
255
- model={model}
256
- setModel={handleModelChange}
257
- provider={provider}
258
- setProvider={handleProviderChange}
259
- messageRef={messageRef}
260
- scrollRef={scrollRef}
261
- handleInputChange={handleInputChange}
262
- handleStop={abort}
263
- description={description}
264
- importChat={importChat}
265
- exportChat={exportChat}
266
- messages={messages.map((message, i) => {
267
- if (message.role === 'user') {
268
- return message;
269
- }
270
-
271
- return {
272
- ...message,
273
- content: parsedMessages[i] || '',
274
- };
275
- })}
276
- enhancePrompt={() => {
277
- enhancePrompt(
278
- input,
279
- (input) => {
280
- setInput(input);
281
- scrollTextArea();
282
- },
283
- model,
284
- provider,
285
- apiKeys,
286
- );
287
- }}
288
- />
289
- );
290
- });
 
35
 
36
  return (
37
  <>
38
+ {ready && (
39
+ <ChatImpl
40
+ description={title}
41
+ initialMessages={initialMessages}
42
+ exportChat={exportChat}
43
+ storeMessageHistory={storeMessageHistory}
44
+ importChat={importChat}
45
+ />
46
+ )}
47
  <ToastContainer
48
  closeButton={({ closeToast }) => {
49
  return (
 
82
  exportChat: () => void;
83
  }
84
 
85
+ export const ChatImpl = memo(
86
+ ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
87
+ useShortcuts();
88
+
89
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
90
+
91
+ const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
92
+ const [model, setModel] = useState(() => {
93
+ const savedModel = Cookies.get('selectedModel');
94
+ return savedModel || DEFAULT_MODEL;
95
+ });
96
+ const [provider, setProvider] = useState(() => {
97
+ const savedProvider = Cookies.get('selectedProvider');
98
+ return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
99
+ });
100
+
101
+ const { showChat } = useStore(chatStore);
102
+
103
+ const [animationScope, animate] = useAnimate();
104
+
105
+ const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
106
+
107
+ const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
108
+ api: '/api/chat',
109
+ body: {
110
+ apiKeys,
111
+ },
112
+ onError: (error) => {
113
+ logger.error('Request failed\n\n', error);
114
+ toast.error(
115
+ 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
116
+ );
117
+ },
118
+ onFinish: () => {
119
+ logger.debug('Finished streaming');
120
+ },
121
+ initialMessages,
122
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
+ const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
125
+ const { parsedMessages, parseMessages } = useMessageParser();
126
 
127
+ const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
 
 
 
128
 
129
+ useEffect(() => {
130
+ chatStore.setKey('started', initialMessages.length > 0);
131
+ }, []);
 
 
132
 
133
+ useEffect(() => {
134
+ parseMessages(messages, isLoading);
135
 
136
+ if (messages.length > initialMessages.length) {
137
+ storeMessageHistory(messages).catch((error) => toast.error(error.message));
138
+ }
139
+ }, [messages, isLoading, parseMessages]);
140
 
141
+ const scrollTextArea = () => {
142
+ const textarea = textareaRef.current;
143
 
144
+ if (textarea) {
145
+ textarea.scrollTop = textarea.scrollHeight;
146
+ }
147
+ };
148
 
149
+ const abort = () => {
150
+ stop();
151
+ chatStore.setKey('aborted', true);
152
+ workbenchStore.abortAllActions();
153
+ };
154
 
155
+ useEffect(() => {
156
+ const textarea = textareaRef.current;
 
 
157
 
158
+ if (textarea) {
159
+ textarea.style.height = 'auto';
160
 
161
+ const scrollHeight = textarea.scrollHeight;
 
162
 
163
+ textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
164
+ textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
165
+ }
166
+ }, [input, textareaRef]);
167
 
168
+ const runAnimation = async () => {
169
+ if (chatStarted) {
170
+ return;
171
+ }
172
 
173
+ await Promise.all([
174
+ animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
175
+ animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
176
+ ]);
 
 
 
 
177
 
178
+ chatStore.setKey('started', true);
179
 
180
+ setChatStarted(true);
181
+ };
182
 
183
+ const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
184
+ const _input = messageInput || input;
185
 
186
+ if (_input.length === 0 || isLoading) {
187
+ return;
188
+ }
189
 
190
  /**
191
+ * @note (delm) Usually saving files shouldn't take long but it may take longer if there
192
+ * many unsaved files. In that case we need to block user input and show an indicator
193
+ * of some kind so the user is aware that something is happening. But I consider the
194
+ * happy case to be no unsaved files and I would expect users to save their changes
195
+ * before they send another message.
196
  */
197
+ await workbenchStore.saveAllFiles();
198
+
199
+ const fileModifications = workbenchStore.getFileModifcations();
200
+
201
+ chatStore.setKey('aborted', false);
202
+
203
+ runAnimation();
204
+
205
+ if (fileModifications !== undefined) {
206
+ const diff = fileModificationsToHTML(fileModifications);
207
+
208
+ /**
209
+ * If we have file modifications we append a new user message manually since we have to prefix
210
+ * the user input with the file modifications and we don't want the new user input to appear
211
+ * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
212
+ * manually reset the input and we'd have to manually pass in file attachments. However, those
213
+ * aren't relevant here.
214
+ */
215
+ append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
216
+
217
+ /**
218
+ * After sending a new message we reset all modifications since the model
219
+ * should now be aware of all the changes.
220
+ */
221
+ workbenchStore.resetAllFileModifications();
222
+ } else {
223
+ append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
224
+ }
225
+
226
+ setInput('');
227
+
228
+ resetEnhancer();
229
+
230
+ textareaRef.current?.blur();
231
+ };
232
+
233
+ const [messageRef, scrollRef] = useSnapScroll();
234
+
235
+ useEffect(() => {
236
+ const storedApiKeys = Cookies.get('apiKeys');
237
+
238
+ if (storedApiKeys) {
239
+ setApiKeys(JSON.parse(storedApiKeys));
240
+ }
241
+ }, []);
242
+
243
+ const handleModelChange = (newModel: string) => {
244
+ setModel(newModel);
245
+ Cookies.set('selectedModel', newModel, { expires: 30 });
246
+ };
247
+
248
+ const handleProviderChange = (newProvider: ProviderInfo) => {
249
+ setProvider(newProvider);
250
+ Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
251
+ };
252
+
253
+ return (
254
+ <BaseChat
255
+ ref={animationScope}
256
+ textareaRef={textareaRef}
257
+ input={input}
258
+ showChat={showChat}
259
+ chatStarted={chatStarted}
260
+ isStreaming={isLoading}
261
+ enhancingPrompt={enhancingPrompt}
262
+ promptEnhanced={promptEnhanced}
263
+ sendMessage={sendMessage}
264
+ model={model}
265
+ setModel={handleModelChange}
266
+ provider={provider}
267
+ setProvider={handleProviderChange}
268
+ messageRef={messageRef}
269
+ scrollRef={scrollRef}
270
+ handleInputChange={handleInputChange}
271
+ handleStop={abort}
272
+ description={description}
273
+ importChat={importChat}
274
+ exportChat={exportChat}
275
+ messages={messages.map((message, i) => {
276
+ if (message.role === 'user') {
277
+ return message;
278
+ }
279
 
280
+ return {
281
+ ...message,
282
+ content: parsedMessages[i] || '',
283
+ };
284
+ })}
285
+ enhancePrompt={() => {
286
+ enhancePrompt(
287
+ input,
288
+ (input) => {
289
+ setInput(input);
290
+ scrollTextArea();
291
+ },
292
+ model,
293
+ provider,
294
+ apiKeys,
295
+ );
296
+ }}
297
+ />
298
+ );
299
+ },
300
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/components/chat/ExportChatButton.tsx CHANGED
@@ -2,13 +2,12 @@ import WithTooltip from '~/components/ui/Tooltip';
2
  import { IconButton } from '~/components/ui/IconButton';
3
  import React from 'react';
4
 
5
- export const ExportChatButton = ({exportChat}: {exportChat: () => void}) => {
6
- return (<WithTooltip tooltip="Export Chat">
7
- <IconButton
8
- title="Export Chat"
9
- onClick={exportChat}
10
- >
11
- <div className="i-ph:download-simple text-xl"></div>
12
- </IconButton>
13
- </WithTooltip>);
14
- }
 
2
  import { IconButton } from '~/components/ui/IconButton';
3
  import React from 'react';
4
 
5
+ export const ExportChatButton = ({ exportChat }: { exportChat: () => void }) => {
6
+ return (
7
+ <WithTooltip tooltip="Export Chat">
8
+ <IconButton title="Export Chat" onClick={exportChat}>
9
+ <div className="i-ph:download-simple text-xl"></div>
10
+ </IconButton>
11
+ </WithTooltip>
12
+ );
13
+ };
 
app/components/chat/Messages.client.tsx CHANGED
@@ -32,6 +32,7 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
32
  toast.error('Chat persistence is not available');
33
  return;
34
  }
 
35
  const urlId = await forkChat(db, chatId.get()!, messageId);
36
  window.location.href = `/chat/${urlId}`;
37
  } catch (error) {
@@ -40,47 +41,48 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
40
  };
41
 
42
  return (
43
- <div id={id} ref={ref} className={props.className}>
44
- {messages.length > 0
45
- ? messages.map((message, index) => {
46
- const { role, content, id: messageId } = message;
47
- const isUserMessage = role === 'user';
48
- const isFirst = index === 0;
49
- const isLast = index === messages.length - 1;
50
 
51
- return (
52
- <div
53
- key={index}
54
- className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
55
- 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
56
- 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
57
- isStreaming && isLast,
58
- 'mt-4': !isFirst,
59
- })}
60
- >
61
- {isUserMessage && (
62
- <div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
63
- <div className="i-ph:user-fill text-xl"></div>
64
- </div>
65
- )}
66
- <div className="grid grid-col-1 w-full">
67
- {isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
68
  </div>
69
- {!isUserMessage && (
70
- <div className="flex gap-2 flex-col lg:flex-row">
71
- <WithTooltip tooltip="Revert to this message">
72
- {messageId && (<button
73
- onClick={() => handleRewind(messageId)}
74
- key="i-ph:arrow-u-up-left"
75
- className={classNames(
76
- 'i-ph:arrow-u-up-left',
77
- 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
78
- )}
 
 
 
 
 
79
  />
80
  )}
81
  </WithTooltip>
82
 
83
- <WithTooltip tooltip="Fork chat from this message">
84
  <button
85
  onClick={() => handleFork(messageId)}
86
  key="i-ph:git-fork"
@@ -90,15 +92,15 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
90
  )}
91
  />
92
  </WithTooltip>
93
- </div>
94
- )}
95
- </div>
96
- );
97
- })
98
- : null}
99
- {isStreaming && (
100
- <div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
101
- )}
102
- </div>
103
  );
104
  });
 
32
  toast.error('Chat persistence is not available');
33
  return;
34
  }
35
+
36
  const urlId = await forkChat(db, chatId.get()!, messageId);
37
  window.location.href = `/chat/${urlId}`;
38
  } catch (error) {
 
41
  };
42
 
43
  return (
44
+ <div id={id} ref={ref} className={props.className}>
45
+ {messages.length > 0
46
+ ? messages.map((message, index) => {
47
+ const { role, content, id: messageId } = message;
48
+ const isUserMessage = role === 'user';
49
+ const isFirst = index === 0;
50
+ const isLast = index === messages.length - 1;
51
 
52
+ return (
53
+ <div
54
+ key={index}
55
+ className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
56
+ 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
57
+ 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
58
+ isStreaming && isLast,
59
+ 'mt-4': !isFirst,
60
+ })}
61
+ >
62
+ {isUserMessage && (
63
+ <div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
64
+ <div className="i-ph:user-fill text-xl"></div>
 
 
 
 
65
  </div>
66
+ )}
67
+ <div className="grid grid-col-1 w-full">
68
+ {isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
69
+ </div>
70
+ {!isUserMessage && (
71
+ <div className="flex gap-2 flex-col lg:flex-row">
72
+ <WithTooltip tooltip="Revert to this message">
73
+ {messageId && (
74
+ <button
75
+ onClick={() => handleRewind(messageId)}
76
+ key="i-ph:arrow-u-up-left"
77
+ className={classNames(
78
+ 'i-ph:arrow-u-up-left',
79
+ 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
80
+ )}
81
  />
82
  )}
83
  </WithTooltip>
84
 
85
+ <WithTooltip tooltip="Fork chat from this message">
86
  <button
87
  onClick={() => handleFork(messageId)}
88
  key="i-ph:git-fork"
 
92
  )}
93
  />
94
  </WithTooltip>
95
+ </div>
96
+ )}
97
+ </div>
98
+ );
99
+ })
100
+ : null}
101
+ {isStreaming && (
102
+ <div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
103
+ )}
104
+ </div>
105
  );
106
  });
app/components/sidebar/HistoryItem.tsx CHANGED
@@ -54,6 +54,7 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
54
  onClick={(event) => {
55
  event.preventDefault();
56
  exportChat(item.id);
 
57
  //exportChat(item.messages, item.description);
58
  }}
59
  title="Export chat"
@@ -70,14 +71,14 @@ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: History
70
  )}
71
  <Dialog.Trigger asChild>
72
  <WithTooltip tooltip="Delete chat">
73
- <button
74
- className="i-ph:trash scale-110"
75
- onClick={(event) => {
76
- // we prevent the default so we don't trigger the anchor above
77
- event.preventDefault();
78
- onDelete?.(event);
79
- }}
80
- />
81
  </WithTooltip>
82
  </Dialog.Trigger>
83
  </div>
 
54
  onClick={(event) => {
55
  event.preventDefault();
56
  exportChat(item.id);
57
+
58
  //exportChat(item.messages, item.description);
59
  }}
60
  title="Export chat"
 
71
  )}
72
  <Dialog.Trigger asChild>
73
  <WithTooltip tooltip="Delete chat">
74
+ <button
75
+ className="i-ph:trash scale-110"
76
+ onClick={(event) => {
77
+ // we prevent the default so we don't trigger the anchor above
78
+ event.preventDefault();
79
+ onDelete?.(event);
80
+ }}
81
+ />
82
  </WithTooltip>
83
  </Dialog.Trigger>
84
  </div>
app/components/sidebar/Menu.client.tsx CHANGED
@@ -16,8 +16,8 @@ const menuVariants = {
16
  left: '-150px',
17
  transition: {
18
  duration: 0.2,
19
- ease: cubicEasingFn
20
- }
21
  },
22
  open: {
23
  opacity: 1,
@@ -25,9 +25,9 @@ const menuVariants = {
25
  left: 0,
26
  transition: {
27
  duration: 0.2,
28
- ease: cubicEasingFn
29
- }
30
- }
31
  } satisfies Variants;
32
 
33
  type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
@@ -134,8 +134,7 @@ export function Menu() {
134
  <DialogRoot open={dialogContent !== null}>
135
  {binDates(list).map(({ category, items }) => (
136
  <div key={category} className="mt-4 first:mt-0 space-y-1">
137
- <div
138
- className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
139
  {category}
140
  </div>
141
  {items.map((item) => (
 
16
  left: '-150px',
17
  transition: {
18
  duration: 0.2,
19
+ ease: cubicEasingFn,
20
+ },
21
  },
22
  open: {
23
  opacity: 1,
 
25
  left: 0,
26
  transition: {
27
  duration: 0.2,
28
+ ease: cubicEasingFn,
29
+ },
30
+ },
31
  } satisfies Variants;
32
 
33
  type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
 
134
  <DialogRoot open={dialogContent !== null}>
135
  {binDates(list).map(({ category, items }) => (
136
  <div key={category} className="mt-4 first:mt-0 space-y-1">
137
+ <div className="text-bolt-elements-textTertiary sticky top-0 z-1 bg-bolt-elements-background-depth-2 pl-2 pt-2 pb-1">
 
138
  {category}
139
  </div>
140
  {items.map((item) => (
app/components/ui/Tooltip.tsx CHANGED
@@ -1,27 +1,32 @@
1
  import React from 'react';
2
  import * as Tooltip from '@radix-ui/react-tooltip';
3
- import type {ReactNode} from 'react';
4
 
5
  interface ToolTipProps {
6
- tooltip: string,
7
  children: ReactNode | ReactNode[];
8
- sideOffset?: number,
9
- className?: string,
10
- arrowClassName?: string,
11
- tooltipStyle?: any, //TODO better type
12
  }
13
 
14
- const WithTooltip = ({ tooltip, children, sideOffset = 5, className = '', arrowClassName = '', tooltipStyle = {} }: ToolTipProps) => {
 
 
 
 
 
 
 
15
  return (
16
  <Tooltip.Root>
17
- <Tooltip.Trigger asChild>
18
- {children}
19
- </Tooltip.Trigger>
20
  <Tooltip.Portal>
21
  <Tooltip.Content
22
  className={`bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg ${className}`}
23
  sideOffset={sideOffset}
24
- style={{ zIndex: 2000, backgroundColor: "white", ...tooltipStyle }}
25
  >
26
  {tooltip}
27
  <Tooltip.Arrow className={`fill-bolt-elements-tooltip-background ${arrowClassName}`} />
 
1
  import React from 'react';
2
  import * as Tooltip from '@radix-ui/react-tooltip';
3
+ import type { ReactNode } from 'react';
4
 
5
  interface ToolTipProps {
6
+ tooltip: string;
7
  children: ReactNode | ReactNode[];
8
+ sideOffset?: number;
9
+ className?: string;
10
+ arrowClassName?: string;
11
+ tooltipStyle?: any; //TODO better type
12
  }
13
 
14
+ const WithTooltip = ({
15
+ tooltip,
16
+ children,
17
+ sideOffset = 5,
18
+ className = '',
19
+ arrowClassName = '',
20
+ tooltipStyle = {},
21
+ }: ToolTipProps) => {
22
  return (
23
  <Tooltip.Root>
24
+ <Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
 
 
25
  <Tooltip.Portal>
26
  <Tooltip.Content
27
  className={`bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg ${className}`}
28
  sideOffset={sideOffset}
29
+ style={{ zIndex: 2000, backgroundColor: 'white', ...tooltipStyle }}
30
  >
31
  {tooltip}
32
  <Tooltip.Arrow className={`fill-bolt-elements-tooltip-background ${arrowClassName}`} />
app/lib/persistence/db.ts CHANGED
@@ -179,18 +179,21 @@ export async function forkChat(db: IDBDatabase, chatId: string, messageId: strin
179
  return createChatFromMessages(db, chat.description ? `${chat.description} (fork)` : 'Forked chat', messages);
180
  }
181
 
182
-
183
-
184
  export async function duplicateChat(db: IDBDatabase, id: string): Promise<string> {
185
  const chat = await getMessages(db, id);
186
 
187
  if (!chat) {
188
  throw new Error('Chat not found');
189
  }
 
190
  return createChatFromMessages(db, `${chat.description || 'Chat'} (copy)`, chat.messages);
191
  }
192
 
193
- export async function createChatFromMessages(db: IDBDatabase, description: string, messages: Message[]) : Promise<string> {
 
 
 
 
194
  const newId = await getNextId(db);
195
  const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat
196
 
@@ -199,7 +202,7 @@ export async function createChatFromMessages(db: IDBDatabase, description: strin
199
  newId,
200
  messages,
201
  newUrlId, // Use the new urlId
202
- description
203
  );
204
 
205
  return newUrlId; // Return the urlId instead of id for navigation
 
179
  return createChatFromMessages(db, chat.description ? `${chat.description} (fork)` : 'Forked chat', messages);
180
  }
181
 
 
 
182
  export async function duplicateChat(db: IDBDatabase, id: string): Promise<string> {
183
  const chat = await getMessages(db, id);
184
 
185
  if (!chat) {
186
  throw new Error('Chat not found');
187
  }
188
+
189
  return createChatFromMessages(db, `${chat.description || 'Chat'} (copy)`, chat.messages);
190
  }
191
 
192
+ export async function createChatFromMessages(
193
+ db: IDBDatabase,
194
+ description: string,
195
+ messages: Message[],
196
+ ): Promise<string> {
197
  const newId = await getNextId(db);
198
  const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat
199
 
 
202
  newId,
203
  messages,
204
  newUrlId, // Use the new urlId
205
+ description,
206
  );
207
 
208
  return newUrlId; // Return the urlId instead of id for navigation
app/lib/persistence/useChatHistory.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
  openDatabase,
12
  setMessages,
13
  duplicateChat,
14
- createChatFromMessages
15
  } from './db';
16
 
17
  export interface ChatHistoryItem {
@@ -121,7 +121,7 @@ export function useChatHistory() {
121
  console.log(error);
122
  }
123
  },
124
- importChat: async (description: string, messages:Message[]) => {
125
  if (!db) {
126
  return;
127
  }
@@ -131,7 +131,7 @@ export function useChatHistory() {
131
  window.location.href = `/chat/${newId}`;
132
  toast.success('Chat imported successfully');
133
  } catch (error) {
134
- toast.error('Failed to import chat');
135
  }
136
  },
137
  exportChat: async (id = urlId) => {
@@ -155,7 +155,7 @@ export function useChatHistory() {
155
  a.click();
156
  document.body.removeChild(a);
157
  URL.revokeObjectURL(url);
158
- }
159
  };
160
  }
161
 
 
11
  openDatabase,
12
  setMessages,
13
  duplicateChat,
14
+ createChatFromMessages,
15
  } from './db';
16
 
17
  export interface ChatHistoryItem {
 
121
  console.log(error);
122
  }
123
  },
124
+ importChat: async (description: string, messages: Message[]) => {
125
  if (!db) {
126
  return;
127
  }
 
131
  window.location.href = `/chat/${newId}`;
132
  toast.success('Chat imported successfully');
133
  } catch (error) {
134
+ toast.error('Failed to import chat: ' + error.message);
135
  }
136
  },
137
  exportChat: async (id = urlId) => {
 
155
  a.click();
156
  document.body.removeChild(a);
157
  URL.revokeObjectURL(url);
158
+ },
159
  };
160
  }
161
 
app/utils/logger.ts CHANGED
@@ -11,7 +11,7 @@ interface Logger {
11
  setLevel: (level: DebugLevel) => void;
12
  }
13
 
14
- let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
15
 
16
  const isWorker = 'HTMLRewriter' in globalThis;
17
  const supportsColor = !isWorker;
 
11
  setLevel: (level: DebugLevel) => void;
12
  }
13
 
14
+ let currentLevel: DebugLevel = (import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV) ? 'debug' : 'info';
15
 
16
  const isWorker = 'HTMLRewriter' in globalThis;
17
  const supportsColor = !isWorker;