Eduards commited on
Commit
1cb836a
·
unverified ·
2 Parent(s): 7fc8e406e94d20

Merge pull request #372 from wonderwhy-er/import-export-individual-chats

Browse files
app/components/chat/BaseChat.tsx CHANGED
@@ -3,7 +3,7 @@
3
  * Preventing TS checks with files presented in the video for a better presentation.
4
  */
5
  import type { Message } from 'ai';
6
- import React, { type RefCallback, useEffect } from 'react';
7
  import { ClientOnly } from 'remix-utils/client-only';
8
  import { Menu } from '~/components/sidebar/Menu.client';
9
  import { IconButton } from '~/components/ui/IconButton';
@@ -12,12 +12,14 @@ import { classNames } from '~/utils/classNames';
12
  import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants';
13
  import { Messages } from './Messages.client';
14
  import { SendButton } from './SendButton.client';
15
- import { useState } from 'react';
16
  import { APIKeyManager } from './APIKeyManager';
17
  import Cookies from 'js-cookie';
 
 
18
 
19
  import styles from './BaseChat.module.scss';
20
  import type { ProviderInfo } from '~/utils/types';
 
21
 
22
  const EXAMPLE_PROMPTS = [
23
  { text: 'Build a todo app in React using Tailwind' },
@@ -79,6 +81,7 @@ interface BaseChatProps {
79
  chatStarted?: boolean;
80
  isStreaming?: boolean;
81
  messages?: Message[];
 
82
  enhancingPrompt?: boolean;
83
  promptEnhanced?: boolean;
84
  input?: string;
@@ -90,6 +93,8 @@ interface BaseChatProps {
90
  sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
91
  handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
92
  enhancePrompt?: () => void;
 
 
93
  }
94
 
95
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -113,6 +118,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
113
  handleInputChange,
114
  enhancePrompt,
115
  handleStop,
 
 
116
  },
117
  ref,
118
  ) => {
@@ -161,7 +168,68 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
161
  }
162
  };
163
 
164
- return (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  <div
166
  ref={ref}
167
  className={classNames(
@@ -297,6 +365,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
297
  </>
298
  )}
299
  </IconButton>
 
300
  </div>
301
  {input.length > 3 ? (
302
  <div className="text-xs text-bolt-elements-textTertiary">
@@ -309,6 +378,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
309
  </div>
310
  </div>
311
  </div>
 
312
  {!chatStarted && (
313
  <div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
314
  <div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
@@ -334,5 +404,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
334
  </div>
335
  </div>
336
  );
 
 
337
  },
338
  );
 
3
  * Preventing TS checks with files presented in the video for a better presentation.
4
  */
5
  import type { Message } from 'ai';
6
+ import React, { type RefCallback, useEffect, useState } from 'react';
7
  import { ClientOnly } from 'remix-utils/client-only';
8
  import { Menu } from '~/components/sidebar/Menu.client';
9
  import { IconButton } from '~/components/ui/IconButton';
 
12
  import { MODEL_LIST, PROVIDER_LIST, initializeModelList } from '~/utils/constants';
13
  import { Messages } from './Messages.client';
14
  import { SendButton } from './SendButton.client';
 
15
  import { APIKeyManager } from './APIKeyManager';
16
  import Cookies from 'js-cookie';
17
+ import { toast } from 'react-toastify';
18
+ 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 { ExportChatButton } from '~/components/chat/ExportChatButton';
23
 
24
  const EXAMPLE_PROMPTS = [
25
  { text: 'Build a todo app in React using Tailwind' },
 
81
  chatStarted?: boolean;
82
  isStreaming?: boolean;
83
  messages?: Message[];
84
+ description?: string;
85
  enhancingPrompt?: boolean;
86
  promptEnhanced?: boolean;
87
  input?: string;
 
93
  sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
94
  handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
95
  enhancePrompt?: () => void;
96
+ importChat?: (description: string, messages: Message[]) => Promise<void>;
97
+ exportChat?: () => void;
98
  }
99
 
100
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
 
118
  handleInputChange,
119
  enhancePrompt,
120
  handleStop,
121
+ importChat,
122
+ exportChat,
123
  },
124
  ref,
125
  ) => {
 
168
  }
169
  };
170
 
171
+ const chatImportButton = !chatStarted && (
172
+ <div className="flex flex-col items-center justify-center flex-1 p-4">
173
+ <input
174
+ type="file"
175
+ id="chat-import"
176
+ className="hidden"
177
+ accept=".json"
178
+ onChange={async (e) => {
179
+ const file = e.target.files?.[0];
180
+
181
+ if (file && importChat) {
182
+ try {
183
+ const reader = new FileReader();
184
+
185
+ reader.onload = async (e) => {
186
+ try {
187
+ const content = e.target?.result as string;
188
+ const data = JSON.parse(content);
189
+
190
+ if (!Array.isArray(data.messages)) {
191
+ toast.error('Invalid chat file format');
192
+ }
193
+
194
+ await importChat(data.description, data.messages);
195
+ toast.success('Chat imported successfully');
196
+ } catch (error: unknown) {
197
+ if (error instanceof Error) {
198
+ toast.error('Failed to parse chat file: ' + error.message);
199
+ } else {
200
+ toast.error('Failed to parse chat file');
201
+ }
202
+ }
203
+ };
204
+ reader.onerror = () => toast.error('Failed to read chat file');
205
+ reader.readAsText(file);
206
+ } catch (error) {
207
+ toast.error(error instanceof Error ? error.message : 'Failed to import chat');
208
+ }
209
+ e.target.value = ''; // Reset file input
210
+ } else {
211
+ toast.error('Something went wrong');
212
+ }
213
+ }}
214
+ />
215
+ <div className="flex flex-col items-center gap-4 max-w-2xl text-center">
216
+ <div className="flex gap-2">
217
+ <button
218
+ onClick={() => {
219
+ const input = document.getElementById('chat-import');
220
+ input?.click();
221
+ }}
222
+ className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
223
+ >
224
+ <div className="i-ph:upload-simple" />
225
+ Import Chat
226
+ </button>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ );
231
+
232
+ const baseChat = (
233
  <div
234
  ref={ref}
235
  className={classNames(
 
365
  </>
366
  )}
367
  </IconButton>
368
+ {chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
369
  </div>
370
  {input.length > 3 ? (
371
  <div className="text-xs text-bolt-elements-textTertiary">
 
378
  </div>
379
  </div>
380
  </div>
381
+ {chatImportButton}
382
  {!chatStarted && (
383
  <div id="examples" className="relative w-full max-w-xl mx-auto mt-8 flex justify-center">
384
  <div className="flex flex-col space-y-2 [mask-image:linear-gradient(to_bottom,black_0%,transparent_180%)] hover:[mask-image:none]">
 
404
  </div>
405
  </div>
406
  );
407
+
408
+ return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
409
  },
410
  );
app/components/chat/Chat.client.tsx CHANGED
@@ -9,7 +9,7 @@ import { useAnimate } from 'framer-motion';
9
  import { memo, useEffect, useRef, useState } from 'react';
10
  import { cssTransition, toast, ToastContainer } from 'react-toastify';
11
  import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
12
- import { useChatHistory } from '~/lib/persistence';
13
  import { chatStore } from '~/lib/stores/chat';
14
  import { workbenchStore } from '~/lib/stores/workbench';
15
  import { fileModificationsToHTML } from '~/utils/diff';
@@ -30,11 +30,20 @@ const logger = createScopedLogger('Chat');
30
  export function Chat() {
31
  renderLogger.trace('Chat');
32
 
33
- const { ready, initialMessages, storeMessageHistory } = useChatHistory();
 
34
 
35
  return (
36
  <>
37
- {ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
 
 
 
 
 
 
 
 
38
  <ToastContainer
39
  closeButton={({ closeToast }) => {
40
  return (
@@ -69,216 +78,224 @@ export function Chat() {
69
  interface ChatProps {
70
  initialMessages: Message[];
71
  storeMessageHistory: (messages: Message[]) => Promise<void>;
 
 
 
72
  }
73
 
74
- export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProps) => {
75
- useShortcuts();
76
-
77
- const textareaRef = useRef<HTMLTextAreaElement>(null);
78
-
79
- const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
80
- const [model, setModel] = useState(() => {
81
- const savedModel = Cookies.get('selectedModel');
82
- return savedModel || DEFAULT_MODEL;
83
- });
84
- const [provider, setProvider] = useState(() => {
85
- const savedProvider = Cookies.get('selectedProvider');
86
- return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
87
- });
88
-
89
- const { showChat } = useStore(chatStore);
90
-
91
- const [animationScope, animate] = useAnimate();
92
-
93
- const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
94
-
95
- const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
96
- api: '/api/chat',
97
- body: {
98
- apiKeys,
99
- },
100
- onError: (error) => {
101
- logger.error('Request failed\n\n', error);
102
- toast.error(
103
- 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
104
- );
105
- },
106
- onFinish: () => {
107
- logger.debug('Finished streaming');
108
- },
109
- initialMessages,
110
- });
111
-
112
- const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
113
- const { parsedMessages, parseMessages } = useMessageParser();
114
-
115
- const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
116
-
117
- useEffect(() => {
118
- chatStore.setKey('started', initialMessages.length > 0);
119
- }, []);
120
-
121
- useEffect(() => {
122
- parseMessages(messages, isLoading);
123
-
124
- if (messages.length > initialMessages.length) {
125
- storeMessageHistory(messages).catch((error) => toast.error(error.message));
126
- }
127
- }, [messages, isLoading, parseMessages]);
128
 
129
- const scrollTextArea = () => {
130
- const textarea = textareaRef.current;
131
 
132
- if (textarea) {
133
- textarea.scrollTop = textarea.scrollHeight;
134
- }
135
- };
136
 
137
- const abort = () => {
138
- stop();
139
- chatStore.setKey('aborted', true);
140
- workbenchStore.abortAllActions();
141
- };
142
 
143
- useEffect(() => {
144
- const textarea = textareaRef.current;
145
 
146
- if (textarea) {
147
- textarea.style.height = 'auto';
 
 
148
 
149
- const scrollHeight = textarea.scrollHeight;
 
150
 
151
- textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
152
- textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
153
- }
154
- }, [input, textareaRef]);
155
 
156
- const runAnimation = async () => {
157
- if (chatStarted) {
158
- return;
159
- }
 
160
 
161
- await Promise.all([
162
- animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
163
- animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
164
- ]);
165
 
166
- chatStore.setKey('started', true);
 
167
 
168
- setChatStarted(true);
169
- };
170
 
171
- const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
172
- const _input = messageInput || input;
 
 
173
 
174
- if (_input.length === 0 || isLoading) {
175
- return;
176
- }
 
177
 
178
- /**
179
- * @note (delm) Usually saving files shouldn't take long but it may take longer if there
180
- * many unsaved files. In that case we need to block user input and show an indicator
181
- * of some kind so the user is aware that something is happening. But I consider the
182
- * happy case to be no unsaved files and I would expect users to save their changes
183
- * before they send another message.
184
- */
185
- await workbenchStore.saveAllFiles();
186
 
187
- const fileModifications = workbenchStore.getFileModifcations();
188
 
189
- chatStore.setKey('aborted', false);
 
190
 
191
- runAnimation();
 
192
 
193
- if (fileModifications !== undefined) {
194
- const diff = fileModificationsToHTML(fileModifications);
 
195
 
196
  /**
197
- * If we have file modifications we append a new user message manually since we have to prefix
198
- * the user input with the file modifications and we don't want the new user input to appear
199
- * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
200
- * manually reset the input and we'd have to manually pass in file attachments. However, those
201
- * aren't relevant here.
202
  */
203
- append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
204
-
205
- /**
206
- * After sending a new message we reset all modifications since the model
207
- * should now be aware of all the changes.
208
- */
209
- workbenchStore.resetAllFileModifications();
210
- } else {
211
- append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
212
- }
213
-
214
- setInput('');
215
-
216
- resetEnhancer();
217
-
218
- textareaRef.current?.blur();
219
- };
220
-
221
- const [messageRef, scrollRef] = useSnapScroll();
222
-
223
- useEffect(() => {
224
- const storedApiKeys = Cookies.get('apiKeys');
225
-
226
- if (storedApiKeys) {
227
- setApiKeys(JSON.parse(storedApiKeys));
228
- }
229
- }, []);
230
-
231
- const handleModelChange = (newModel: string) => {
232
- setModel(newModel);
233
- Cookies.set('selectedModel', newModel, { expires: 30 });
234
- };
235
-
236
- const handleProviderChange = (newProvider: ProviderInfo) => {
237
- setProvider(newProvider);
238
- Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
239
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- return (
242
- <BaseChat
243
- ref={animationScope}
244
- textareaRef={textareaRef}
245
- input={input}
246
- showChat={showChat}
247
- chatStarted={chatStarted}
248
- isStreaming={isLoading}
249
- enhancingPrompt={enhancingPrompt}
250
- promptEnhanced={promptEnhanced}
251
- sendMessage={sendMessage}
252
- model={model}
253
- setModel={handleModelChange}
254
- provider={provider}
255
- setProvider={handleProviderChange}
256
- messageRef={messageRef}
257
- scrollRef={scrollRef}
258
- handleInputChange={handleInputChange}
259
- handleStop={abort}
260
- messages={messages.map((message, i) => {
261
- if (message.role === 'user') {
262
- return message;
263
- }
264
-
265
- return {
266
- ...message,
267
- content: parsedMessages[i] || '',
268
- };
269
- })}
270
- enhancePrompt={() => {
271
- enhancePrompt(
272
- input,
273
- (input) => {
274
- setInput(input);
275
- scrollTextArea();
276
- },
277
- model,
278
- provider,
279
- apiKeys,
280
- );
281
- }}
282
- />
283
- );
284
- });
 
9
  import { memo, useEffect, useRef, useState } from 'react';
10
  import { cssTransition, toast, ToastContainer } from 'react-toastify';
11
  import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
12
+ import { description, useChatHistory } from '~/lib/persistence';
13
  import { chatStore } from '~/lib/stores/chat';
14
  import { workbenchStore } from '~/lib/stores/workbench';
15
  import { fileModificationsToHTML } from '~/utils/diff';
 
30
  export function Chat() {
31
  renderLogger.trace('Chat');
32
 
33
+ const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
34
+ const title = useStore(description);
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 (
 
78
  interface ChatProps {
79
  initialMessages: Message[];
80
  storeMessageHistory: (messages: Message[]) => Promise<void>;
81
+ importChat: (description: string, messages: Message[]) => Promise<void>;
82
+ exportChat: () => void;
83
+ description?: string;
84
  }
85
 
86
+ export const ChatImpl = memo(
87
+ ({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
88
+ useShortcuts();
89
+
90
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
91
+
92
+ const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
93
+ const [model, setModel] = useState(() => {
94
+ const savedModel = Cookies.get('selectedModel');
95
+ return savedModel || DEFAULT_MODEL;
96
+ });
97
+ const [provider, setProvider] = useState(() => {
98
+ const savedProvider = Cookies.get('selectedProvider');
99
+ return PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER;
100
+ });
101
+
102
+ const { showChat } = useStore(chatStore);
103
+
104
+ const [animationScope, animate] = useAnimate();
105
+
106
+ const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
107
+
108
+ const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({
109
+ api: '/api/chat',
110
+ body: {
111
+ apiKeys,
112
+ },
113
+ onError: (error) => {
114
+ logger.error('Request failed\n\n', error);
115
+ toast.error(
116
+ 'There was an error processing your request: ' + (error.message ? error.message : 'No details were returned'),
117
+ );
118
+ },
119
+ onFinish: () => {
120
+ logger.debug('Finished streaming');
121
+ },
122
+ initialMessages,
123
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
+ const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
126
+ const { parsedMessages, parseMessages } = useMessageParser();
127
 
128
+ const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
 
 
 
129
 
130
+ useEffect(() => {
131
+ chatStore.setKey('started', initialMessages.length > 0);
132
+ }, []);
 
 
133
 
134
+ useEffect(() => {
135
+ parseMessages(messages, isLoading);
136
 
137
+ if (messages.length > initialMessages.length) {
138
+ storeMessageHistory(messages).catch((error) => toast.error(error.message));
139
+ }
140
+ }, [messages, isLoading, parseMessages]);
141
 
142
+ const scrollTextArea = () => {
143
+ const textarea = textareaRef.current;
144
 
145
+ if (textarea) {
146
+ textarea.scrollTop = textarea.scrollHeight;
147
+ }
148
+ };
149
 
150
+ const abort = () => {
151
+ stop();
152
+ chatStore.setKey('aborted', true);
153
+ workbenchStore.abortAllActions();
154
+ };
155
 
156
+ useEffect(() => {
157
+ const textarea = textareaRef.current;
 
 
158
 
159
+ if (textarea) {
160
+ textarea.style.height = 'auto';
161
 
162
+ const scrollHeight = textarea.scrollHeight;
 
163
 
164
+ textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
165
+ textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
166
+ }
167
+ }, [input, textareaRef]);
168
 
169
+ const runAnimation = async () => {
170
+ if (chatStarted) {
171
+ return;
172
+ }
173
 
174
+ await Promise.all([
175
+ animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
176
+ animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
177
+ ]);
 
 
 
 
178
 
179
+ chatStore.setKey('started', true);
180
 
181
+ setChatStarted(true);
182
+ };
183
 
184
+ const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
185
+ const _input = messageInput || input;
186
 
187
+ if (_input.length === 0 || isLoading) {
188
+ return;
189
+ }
190
 
191
  /**
192
+ * @note (delm) Usually saving files shouldn't take long but it may take longer if there
193
+ * many unsaved files. In that case we need to block user input and show an indicator
194
+ * of some kind so the user is aware that something is happening. But I consider the
195
+ * happy case to be no unsaved files and I would expect users to save their changes
196
+ * before they send another message.
197
  */
198
+ await workbenchStore.saveAllFiles();
199
+
200
+ const fileModifications = workbenchStore.getFileModifcations();
201
+
202
+ chatStore.setKey('aborted', false);
203
+
204
+ runAnimation();
205
+
206
+ if (fileModifications !== undefined) {
207
+ const diff = fileModificationsToHTML(fileModifications);
208
+
209
+ /**
210
+ * If we have file modifications we append a new user message manually since we have to prefix
211
+ * the user input with the file modifications and we don't want the new user input to appear
212
+ * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
213
+ * manually reset the input and we'd have to manually pass in file attachments. However, those
214
+ * aren't relevant here.
215
+ */
216
+ append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
217
+
218
+ /**
219
+ * After sending a new message we reset all modifications since the model
220
+ * should now be aware of all the changes.
221
+ */
222
+ workbenchStore.resetAllFileModifications();
223
+ } else {
224
+ append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
225
+ }
226
+
227
+ setInput('');
228
+
229
+ resetEnhancer();
230
+
231
+ textareaRef.current?.blur();
232
+ };
233
+
234
+ const [messageRef, scrollRef] = useSnapScroll();
235
+
236
+ useEffect(() => {
237
+ const storedApiKeys = Cookies.get('apiKeys');
238
+
239
+ if (storedApiKeys) {
240
+ setApiKeys(JSON.parse(storedApiKeys));
241
+ }
242
+ }, []);
243
+
244
+ const handleModelChange = (newModel: string) => {
245
+ setModel(newModel);
246
+ Cookies.set('selectedModel', newModel, { expires: 30 });
247
+ };
248
+
249
+ const handleProviderChange = (newProvider: ProviderInfo) => {
250
+ setProvider(newProvider);
251
+ Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
252
+ };
253
+
254
+ return (
255
+ <BaseChat
256
+ ref={animationScope}
257
+ textareaRef={textareaRef}
258
+ input={input}
259
+ showChat={showChat}
260
+ chatStarted={chatStarted}
261
+ isStreaming={isLoading}
262
+ enhancingPrompt={enhancingPrompt}
263
+ promptEnhanced={promptEnhanced}
264
+ sendMessage={sendMessage}
265
+ model={model}
266
+ setModel={handleModelChange}
267
+ provider={provider}
268
+ setProvider={handleProviderChange}
269
+ messageRef={messageRef}
270
+ scrollRef={scrollRef}
271
+ handleInputChange={handleInputChange}
272
+ handleStop={abort}
273
+ description={description}
274
+ importChat={importChat}
275
+ exportChat={exportChat}
276
+ messages={messages.map((message, i) => {
277
+ if (message.role === 'user') {
278
+ return message;
279
+ }
280
 
281
+ return {
282
+ ...message,
283
+ content: parsedMessages[i] || '',
284
+ };
285
+ })}
286
+ enhancePrompt={() => {
287
+ enhancePrompt(
288
+ input,
289
+ (input) => {
290
+ setInput(input);
291
+ scrollTextArea();
292
+ },
293
+ model,
294
+ provider,
295
+ apiKeys,
296
+ );
297
+ }}
298
+ />
299
+ );
300
+ },
301
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/components/chat/ExportChatButton.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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 (
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
@@ -3,11 +3,11 @@ import React from 'react';
3
  import { classNames } from '~/utils/classNames';
4
  import { AssistantMessage } from './AssistantMessage';
5
  import { UserMessage } from './UserMessage';
6
- import * as Tooltip from '@radix-ui/react-tooltip';
7
  import { useLocation } from '@remix-run/react';
8
  import { db, chatId } from '~/lib/persistence/useChatHistory';
9
  import { forkChat } from '~/lib/persistence/db';
10
  import { toast } from 'react-toastify';
 
11
 
12
  interface MessagesProps {
13
  id?: string;
@@ -41,92 +41,66 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
41
  };
42
 
43
  return (
44
- <Tooltip.Provider delayDuration={200}>
45
- <div id={id} ref={ref} className={props.className}>
46
- {messages.length > 0
47
- ? messages.map((message, index) => {
48
- const { role, content, id: messageId } = message;
49
- const isUserMessage = role === 'user';
50
- const isFirst = index === 0;
51
- const isLast = index === messages.length - 1;
52
 
53
- return (
54
- <div
55
- key={index}
56
- className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
57
- 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
58
- 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
59
- isStreaming && isLast,
60
- 'mt-4': !isFirst,
61
- })}
62
- >
63
- {isUserMessage && (
64
- <div className="flex items-center justify-center w-[34px] h-[34px] overflow-hidden bg-white text-gray-600 rounded-full shrink-0 self-start">
65
- <div className="i-ph:user-fill text-xl"></div>
66
- </div>
67
- )}
68
- <div className="grid grid-col-1 w-full">
69
- {isUserMessage ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
70
  </div>
71
- {!isUserMessage && (
72
- <div className="flex gap-2 flex-col lg:flex-row">
73
- <Tooltip.Root>
74
- <Tooltip.Trigger asChild>
75
- {messageId && (
76
- <button
77
- onClick={() => handleRewind(messageId)}
78
- key="i-ph:arrow-u-up-left"
79
- className={classNames(
80
- 'i-ph:arrow-u-up-left',
81
- 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
82
- )}
83
- />
 
84
  )}
85
- </Tooltip.Trigger>
86
- <Tooltip.Portal>
87
- <Tooltip.Content
88
- className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
89
- sideOffset={5}
90
- style={{ zIndex: 1000 }}
91
- >
92
- Revert to this message
93
- <Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
94
- </Tooltip.Content>
95
- </Tooltip.Portal>
96
- </Tooltip.Root>
97
 
98
- <Tooltip.Root>
99
- <Tooltip.Trigger asChild>
100
- <button
101
- onClick={() => handleFork(messageId)}
102
- key="i-ph:git-fork"
103
- className={classNames(
104
- 'i-ph:git-fork',
105
- 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
106
- )}
107
- />
108
- </Tooltip.Trigger>
109
- <Tooltip.Portal>
110
- <Tooltip.Content
111
- className="bg-bolt-elements-tooltip-background text-bolt-elements-textPrimary px-3 py-2 rounded-lg text-sm shadow-lg"
112
- sideOffset={5}
113
- style={{ zIndex: 1000 }}
114
- >
115
- Fork chat from this message
116
- <Tooltip.Arrow className="fill-bolt-elements-tooltip-background" />
117
- </Tooltip.Content>
118
- </Tooltip.Portal>
119
- </Tooltip.Root>
120
- </div>
121
- )}
122
- </div>
123
- );
124
- })
125
- : null}
126
- {isStreaming && (
127
- <div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
128
- )}
129
- </div>
130
- </Tooltip.Provider>
131
  );
132
  });
 
3
  import { classNames } from '~/utils/classNames';
4
  import { AssistantMessage } from './AssistantMessage';
5
  import { UserMessage } from './UserMessage';
 
6
  import { useLocation } from '@remix-run/react';
7
  import { db, chatId } from '~/lib/persistence/useChatHistory';
8
  import { forkChat } from '~/lib/persistence/db';
9
  import { toast } from 'react-toastify';
10
+ import WithTooltip from '~/components/ui/Tooltip';
11
 
12
  interface MessagesProps {
13
  id?: string;
 
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"
89
+ className={classNames(
90
+ 'i-ph:git-fork',
91
+ 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
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
@@ -1,14 +1,16 @@
1
  import * as Dialog from '@radix-ui/react-dialog';
2
  import { useEffect, useRef, useState } from 'react';
3
  import { type ChatHistoryItem } from '~/lib/persistence';
 
4
 
5
  interface HistoryItemProps {
6
  item: ChatHistoryItem;
7
  onDelete?: (event: React.UIEvent) => void;
8
  onDuplicate?: (id: string) => void;
 
9
  }
10
 
11
- export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) {
12
  const [hovering, setHovering] = useState(false);
13
  const hoverRef = useRef<HTMLDivElement>(null);
14
 
@@ -43,25 +45,41 @@ export function HistoryItem({ item, onDelete, onDuplicate }: HistoryItemProps) {
43
  >
44
  <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
45
  {item.description}
46
- <div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
47
  {hovering && (
48
  <div className="flex items-center p-1 text-bolt-elements-textSecondary">
49
- {onDuplicate && (
50
- <button
51
- className="i-ph:copy scale-110 mr-2"
52
- onClick={() => onDuplicate?.(item.id)}
53
- title="Duplicate chat"
54
- />
55
- )}
56
- <Dialog.Trigger asChild>
57
  <button
58
- className="i-ph:trash scale-110"
59
  onClick={(event) => {
60
- // we prevent the default so we don't trigger the anchor above
61
  event.preventDefault();
62
- onDelete?.(event);
 
 
63
  }}
 
64
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  </Dialog.Trigger>
66
  </div>
67
  )}
 
1
  import * as Dialog from '@radix-ui/react-dialog';
2
  import { useEffect, useRef, useState } from 'react';
3
  import { type ChatHistoryItem } from '~/lib/persistence';
4
+ import WithTooltip from '~/components/ui/Tooltip';
5
 
6
  interface HistoryItemProps {
7
  item: ChatHistoryItem;
8
  onDelete?: (event: React.UIEvent) => void;
9
  onDuplicate?: (id: string) => void;
10
+ exportChat: (id?: string) => void;
11
  }
12
 
13
+ export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
14
  const [hovering, setHovering] = useState(false);
15
  const hoverRef = useRef<HTMLDivElement>(null);
16
 
 
45
  >
46
  <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
47
  {item.description}
48
+ <div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 box-content pl-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-99%">
49
  {hovering && (
50
  <div className="flex items-center p-1 text-bolt-elements-textSecondary">
51
+ <WithTooltip tooltip="Export chat">
 
 
 
 
 
 
 
52
  <button
53
+ className="i-ph:download-simple scale-110 mr-2"
54
  onClick={(event) => {
 
55
  event.preventDefault();
56
+ exportChat(item.id);
57
+
58
+ //exportChat(item.messages, item.description);
59
  }}
60
+ title="Export chat"
61
  />
62
+ </WithTooltip>
63
+ {onDuplicate && (
64
+ <WithTooltip tooltip="Duplicate chat">
65
+ <button
66
+ className="i-ph:copy scale-110 mr-2"
67
+ onClick={() => onDuplicate?.(item.id)}
68
+ title="Duplicate chat"
69
+ />
70
+ </WithTooltip>
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>
85
  )}
app/components/sidebar/Menu.client.tsx CHANGED
@@ -33,7 +33,7 @@ const menuVariants = {
33
  type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
34
 
35
  export function Menu() {
36
- const { duplicateCurrentChat } = useChatHistory();
37
  const menuRef = useRef<HTMLDivElement>(null);
38
  const [list, setList] = useState<ChatHistoryItem[]>([]);
39
  const [open, setOpen] = useState(false);
@@ -101,7 +101,6 @@ export function Menu() {
101
 
102
  const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
103
  event.preventDefault();
104
-
105
  setDialogContent({ type: 'delete', item });
106
  };
107
 
@@ -142,6 +141,7 @@ export function Menu() {
142
  <HistoryItem
143
  key={item.id}
144
  item={item}
 
145
  onDelete={(event) => handleDeleteClick(event, item)}
146
  onDuplicate={() => handleDuplicate(item.id)}
147
  />
 
33
  type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
34
 
35
  export function Menu() {
36
+ const { duplicateCurrentChat, exportChat } = useChatHistory();
37
  const menuRef = useRef<HTMLDivElement>(null);
38
  const [list, setList] = useState<ChatHistoryItem[]>([]);
39
  const [open, setOpen] = useState(false);
 
101
 
102
  const handleDeleteClick = (event: React.UIEvent, item: ChatHistoryItem) => {
103
  event.preventDefault();
 
104
  setDialogContent({ type: 'delete', item });
105
  };
106
 
 
141
  <HistoryItem
142
  key={item.id}
143
  item={item}
144
+ exportChat={exportChat}
145
  onDelete={(event) => handleDeleteClick(event, item)}
146
  onDuplicate={() => handleDuplicate(item.id)}
147
  />
app/components/ui/Tooltip.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}`} />
33
+ </Tooltip.Content>
34
+ </Tooltip.Portal>
35
+ </Tooltip.Root>
36
+ );
37
+ };
38
+
39
+ export default WithTooltip;
app/lib/persistence/db.ts CHANGED
@@ -176,14 +176,7 @@ export async function forkChat(db: IDBDatabase, chatId: string, messageId: strin
176
  // Get messages up to and including the selected message
177
  const messages = chat.messages.slice(0, messageIndex + 1);
178
 
179
- // Generate new IDs
180
- const newId = await getNextId(db);
181
- const urlId = await getUrlId(db, newId);
182
-
183
- // Create the forked chat
184
- await setMessages(db, newId, messages, urlId, chat.description ? `${chat.description} (fork)` : 'Forked chat');
185
-
186
- return urlId;
187
  }
188
 
189
  export async function duplicateChat(db: IDBDatabase, id: string): Promise<string> {
@@ -193,15 +186,23 @@ export async function duplicateChat(db: IDBDatabase, id: string): Promise<string
193
  throw new Error('Chat not found');
194
  }
195
 
 
 
 
 
 
 
 
 
196
  const newId = await getNextId(db);
197
  const newUrlId = await getUrlId(db, newId); // Get a new urlId for the duplicated chat
198
 
199
  await setMessages(
200
  db,
201
  newId,
202
- chat.messages,
203
  newUrlId, // Use the new urlId
204
- `${chat.description || 'Chat'} (copy)`,
205
  );
206
 
207
  return newUrlId; // Return the urlId instead of id for navigation
 
176
  // Get messages up to and including the selected message
177
  const messages = chat.messages.slice(0, messageIndex + 1);
178
 
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> {
 
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
 
200
  await setMessages(
201
  db,
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
@@ -4,7 +4,15 @@ import { atom } from 'nanostores';
4
  import type { Message } from 'ai';
5
  import { toast } from 'react-toastify';
6
  import { workbenchStore } from '~/lib/stores/workbench';
7
- import { getMessages, getNextId, getUrlId, openDatabase, setMessages, duplicateChat } from './db';
 
 
 
 
 
 
 
 
8
 
9
  export interface ChatHistoryItem {
10
  id: string;
@@ -113,6 +121,45 @@ export function useChatHistory() {
113
  console.log(error);
114
  }
115
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  };
117
  }
118
 
 
4
  import type { Message } from 'ai';
5
  import { toast } from 'react-toastify';
6
  import { workbenchStore } from '~/lib/stores/workbench';
7
+ import {
8
+ getMessages,
9
+ getNextId,
10
+ getUrlId,
11
+ openDatabase,
12
+ setMessages,
13
+ duplicateChat,
14
+ createChatFromMessages,
15
+ } from './db';
16
 
17
  export interface ChatHistoryItem {
18
  id: string;
 
121
  console.log(error);
122
  }
123
  },
124
+ importChat: async (description: string, messages: Message[]) => {
125
+ if (!db) {
126
+ return;
127
+ }
128
+
129
+ try {
130
+ const newId = await createChatFromMessages(db, description, messages);
131
+ window.location.href = `/chat/${newId}`;
132
+ toast.success('Chat imported successfully');
133
+ } catch (error) {
134
+ if (error instanceof Error) {
135
+ toast.error('Failed to import chat: ' + error.message);
136
+ } else {
137
+ toast.error('Failed to import chat');
138
+ }
139
+ }
140
+ },
141
+ exportChat: async (id = urlId) => {
142
+ if (!db || !id) {
143
+ return;
144
+ }
145
+
146
+ const chat = await getMessages(db, id);
147
+ const chatData = {
148
+ messages: chat.messages,
149
+ description: chat.description,
150
+ exportDate: new Date().toISOString(),
151
+ };
152
+
153
+ const blob = new Blob([JSON.stringify(chatData, null, 2)], { type: 'application/json' });
154
+ const url = URL.createObjectURL(blob);
155
+ const a = document.createElement('a');
156
+ a.href = url;
157
+ a.download = `chat-${new Date().toISOString()}.json`;
158
+ document.body.appendChild(a);
159
+ a.click();
160
+ document.body.removeChild(a);
161
+ URL.revokeObjectURL(url);
162
+ },
163
  };
164
  }
165
 
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;