codacus commited on
Commit
5ead479
·
unverified ·
2 Parent(s): 7192690 a0eb0a0

Merge branch 'main' into together-ai-dynamic-model-list

Browse files
.github/ISSUE_TEMPLATE/bug_report.yml CHANGED
@@ -56,6 +56,16 @@ body:
56
  - OS: [e.g. macOS, Windows, Linux]
57
  - Browser: [e.g. Chrome, Safari, Firefox]
58
  - Version: [e.g. 91.1]
 
 
 
 
 
 
 
 
 
 
59
  - type: textarea
60
  id: additional
61
  attributes:
 
56
  - OS: [e.g. macOS, Windows, Linux]
57
  - Browser: [e.g. Chrome, Safari, Firefox]
58
  - Version: [e.g. 91.1]
59
+ - type: input
60
+ id: provider
61
+ attributes:
62
+ label: Provider Used
63
+ description: Tell us the provider you are using.
64
+ - type: input
65
+ id: model
66
+ attributes:
67
+ label: Model Used
68
+ description: Tell us the model you are using.
69
  - type: textarea
70
  id: additional
71
  attributes:
.github/workflows/stale.yml CHANGED
@@ -16,10 +16,10 @@ jobs:
16
  repo-token: ${{ secrets.GITHUB_TOKEN }}
17
  stale-issue-message: "This issue has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days."
18
  stale-pr-message: "This pull request has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days."
19
- days-before-stale: 14 # Number of days before marking an issue or PR as stale
20
- days-before-close: 7 # Number of days after being marked stale before closing
21
  stale-issue-label: "stale" # Label to apply to stale issues
22
  stale-pr-label: "stale" # Label to apply to stale pull requests
23
  exempt-issue-labels: "pinned,important" # Issues with these labels won't be marked stale
24
  exempt-pr-labels: "pinned,important" # PRs with these labels won't be marked stale
25
- operations-per-run: 90 # Limits the number of actions per run to avoid API rate limits
 
16
  repo-token: ${{ secrets.GITHUB_TOKEN }}
17
  stale-issue-message: "This issue has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days."
18
  stale-pr-message: "This pull request has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days."
19
+ days-before-stale: 10 # Number of days before marking an issue or PR as stale
20
+ days-before-close: 4 # Number of days after being marked stale before closing
21
  stale-issue-label: "stale" # Label to apply to stale issues
22
  stale-pr-label: "stale" # Label to apply to stale pull requests
23
  exempt-issue-labels: "pinned,important" # Issues with these labels won't be marked stale
24
  exempt-pr-labels: "pinned,important" # PRs with these labels won't be marked stale
25
+ operations-per-run: 75 # Limits the number of actions per run to avoid API rate limits
.husky/pre-commit CHANGED
@@ -2,6 +2,9 @@
2
 
3
  echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
4
 
 
 
 
5
  if ! pnpm typecheck; then
6
  echo "❌ Type checking failed! Please review TypeScript types."
7
  echo "Once you're done, don't forget to add your changes to the commit! 🚀"
@@ -9,7 +12,7 @@ if ! pnpm typecheck; then
9
  fi
10
 
11
  if ! pnpm lint; then
12
- echo "❌ Linting failed! 'pnpm lint:check' will help you fix the easy ones."
13
  echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
14
  exit 1
15
  fi
 
2
 
3
  echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
4
 
5
+ export NVM_DIR="$HOME/.nvm"
6
+ [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # Load nvm if you're using i
7
+
8
  if ! pnpm typecheck; then
9
  echo "❌ Type checking failed! Please review TypeScript types."
10
  echo "Once you're done, don't forget to add your changes to the commit! 🚀"
 
12
  fi
13
 
14
  if ! pnpm lint; then
15
+ echo "❌ Linting failed! 'pnpm lint:fix' will help you fix the easy ones."
16
  echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
17
  exit 1
18
  fi
README.md CHANGED
@@ -4,10 +4,13 @@
4
 
5
  This fork of Bolt.new (oTToDev) allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
6
 
 
 
7
  ## Join the community for oTToDev!
8
 
9
  https://thinktank.ottomator.ai
10
 
 
11
  ## Requested Additions - Feel Free to Contribute!
12
 
13
  - ✅ OpenRouter Integration (@coleam00)
@@ -31,23 +34,24 @@ https://thinktank.ottomator.ai
31
  - ✅ Ability to revert code to earlier version (@wonderwhy-er)
32
  - ✅ Cohere Integration (@hasanraiyan)
33
  - ✅ Dynamic model max token length (@hasanraiyan)
 
34
  - ✅ Prompt caching (@SujalXplores)
35
  - ✅ Load local projects into the app (@wonderwhy-er)
36
  - ✅ Together Integration (@mouimet-infinisoft)
37
  - ✅ Mobile friendly (@qwikode)
38
  - ✅ Better prompt enhancing (@SujalXplores)
39
- - **HIGH PRIORITY** - ALMOST DONE - Attach images to prompts (@atrokhym)
40
  - ⬜ **HIGH PRIORITY** - Prevent Bolt from rewriting files as often (file locking and diffs)
41
  - ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
42
  - ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
43
- - ⬜ Azure Open AI API Integration
44
- - ⬜ Perplexity Integration
45
- - ⬜ Vertex AI Integration
46
  - ⬜ Deploy directly to Vercel/Netlify/other similar platforms
47
  - ⬜ Have LLM plan the project in a MD file for better results/transparency
48
  - ⬜ VSCode Integration with git-like confirmations
49
  - ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
50
  - ⬜ Voice prompting
 
 
 
51
 
52
  ## Bolt.new: AI-Powered Full-Stack Web Development in the Browser
53
 
 
4
 
5
  This fork of Bolt.new (oTToDev) allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
6
 
7
+ Check the [oTToDev Docs](https://coleam00.github.io/bolt.new-any-llm/) for more information.
8
+
9
  ## Join the community for oTToDev!
10
 
11
  https://thinktank.ottomator.ai
12
 
13
+
14
  ## Requested Additions - Feel Free to Contribute!
15
 
16
  - ✅ OpenRouter Integration (@coleam00)
 
34
  - ✅ Ability to revert code to earlier version (@wonderwhy-er)
35
  - ✅ Cohere Integration (@hasanraiyan)
36
  - ✅ Dynamic model max token length (@hasanraiyan)
37
+ - ✅ Better prompt enhancing (@SujalXplores)
38
  - ✅ Prompt caching (@SujalXplores)
39
  - ✅ Load local projects into the app (@wonderwhy-er)
40
  - ✅ Together Integration (@mouimet-infinisoft)
41
  - ✅ Mobile friendly (@qwikode)
42
  - ✅ Better prompt enhancing (@SujalXplores)
43
+ - Attach images to prompts (@atrokhym)
44
  - ⬜ **HIGH PRIORITY** - Prevent Bolt from rewriting files as often (file locking and diffs)
45
  - ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
46
  - ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
 
 
 
47
  - ⬜ Deploy directly to Vercel/Netlify/other similar platforms
48
  - ⬜ Have LLM plan the project in a MD file for better results/transparency
49
  - ⬜ VSCode Integration with git-like confirmations
50
  - ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
51
  - ⬜ Voice prompting
52
+ - ⬜ Azure Open AI API Integration
53
+ - ⬜ Perplexity Integration
54
+ - ⬜ Vertex AI Integration
55
 
56
  ## Bolt.new: AI-Powered Full-Stack Web Development in the Browser
57
 
app/components/chat/BaseChat.tsx CHANGED
@@ -22,44 +22,9 @@ import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportCh
22
  import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
23
  import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
24
 
25
- // @ts-ignore TODO: Introduce proper types
26
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
27
- const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => {
28
- return (
29
- <div className="mb-2 flex gap-2 flex-col sm:flex-row">
30
- <select
31
- value={provider?.name}
32
- onChange={(e) => {
33
- setProvider(providerList.find((p: ProviderInfo) => p.name === e.target.value));
34
-
35
- const firstModel = [...modelList].find((m) => m.provider == e.target.value);
36
- setModel(firstModel ? firstModel.name : '');
37
- }}
38
- className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
39
- >
40
- {providerList.map((provider: ProviderInfo) => (
41
- <option key={provider.name} value={provider.name}>
42
- {provider.name}
43
- </option>
44
- ))}
45
- </select>
46
- <select
47
- key={provider?.name}
48
- value={model}
49
- onChange={(e) => setModel(e.target.value)}
50
- className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%]"
51
- >
52
- {[...modelList]
53
- .filter((e) => e.provider == provider?.name && e.name)
54
- .map((modelOption) => (
55
- <option key={modelOption.name} value={modelOption.name}>
56
- {modelOption.label}
57
- </option>
58
- ))}
59
- </select>
60
- </div>
61
- );
62
- };
63
 
64
  const TEXTAREA_MIN_HEIGHT = 76;
65
 
@@ -85,6 +50,10 @@ interface BaseChatProps {
85
  enhancePrompt?: () => void;
86
  importChat?: (description: string, messages: Message[]) => Promise<void>;
87
  exportChat?: () => void;
 
 
 
 
88
  }
89
 
90
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -96,20 +65,24 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
96
  showChat = true,
97
  chatStarted = false,
98
  isStreaming = false,
99
- enhancingPrompt = false,
100
- promptEnhanced = false,
101
- messages,
102
- input = '',
103
  model,
104
  setModel,
105
  provider,
106
  setProvider,
107
- sendMessage,
 
108
  handleInputChange,
 
109
  enhancePrompt,
 
110
  handleStop,
111
  importChat,
112
  exportChat,
 
 
 
 
 
113
  },
114
  ref,
115
  ) => {
@@ -117,7 +90,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
117
  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
118
  const [modelList, setModelList] = useState(MODEL_LIST);
119
  const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
 
 
 
120
 
 
121
  useEffect(() => {
122
  // Load API keys from cookies on component mount
123
  try {
@@ -140,8 +117,72 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
140
  initializeModelList().then((modelList) => {
141
  setModelList(modelList);
142
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  }, []);
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  const updateApiKey = (provider: string, key: string) => {
146
  try {
147
  const updatedApiKeys = { ...apiKeys, [provider]: key };
@@ -159,6 +200,58 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
159
  }
160
  };
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  const baseChat = (
163
  <div
164
  ref={ref}
@@ -275,7 +368,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
275
  )}
276
  </div>
277
  </div>
278
-
 
 
 
 
 
 
 
279
  <div
280
  className={classNames(
281
  'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
@@ -283,9 +383,41 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
283
  >
284
  <textarea
285
  ref={textareaRef}
286
- className={
287
- 'w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm'
288
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  onKeyDown={(event) => {
290
  if (event.key === 'Enter') {
291
  if (event.shiftKey) {
@@ -294,13 +426,19 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
294
 
295
  event.preventDefault();
296
 
297
- sendMessage?.(event);
 
 
 
 
 
298
  }
299
  }}
300
  value={input}
301
  onChange={(event) => {
302
  handleInputChange?.(event);
303
  }}
 
304
  style={{
305
  minHeight: TEXTAREA_MIN_HEIGHT,
306
  maxHeight: TEXTAREA_MAX_HEIGHT,
@@ -311,7 +449,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
311
  <ClientOnly>
312
  {() => (
313
  <SendButton
314
- show={input.length > 0 || isStreaming}
315
  isStreaming={isStreaming}
316
  onClick={(event) => {
317
  if (isStreaming) {
@@ -319,21 +457,28 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
319
  return;
320
  }
321
 
322
- sendMessage?.(event);
 
 
323
  }}
324
  />
325
  )}
326
  </ClientOnly>
327
  <div className="flex justify-between items-center text-sm p-4 pt-2">
328
  <div className="flex gap-1 items-center">
 
 
 
329
  <IconButton
330
  title="Enhance prompt"
331
  disabled={input.length === 0 || enhancingPrompt}
332
- className={classNames('transition-all', {
333
- 'opacity-100!': enhancingPrompt,
334
- 'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
335
- promptEnhanced,
336
- })}
 
 
337
  onClick={() => enhancePrompt?.()}
338
  >
339
  {enhancingPrompt ? (
@@ -348,6 +493,13 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
348
  </>
349
  )}
350
  </IconButton>
 
 
 
 
 
 
 
351
  {chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
352
  </div>
353
  {input.length > 3 ? (
@@ -362,7 +514,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
362
  </div>
363
  </div>
364
  {!chatStarted && ImportButtons(importChat)}
365
- {!chatStarted && ExamplePrompts(sendMessage)}
 
 
 
 
 
 
 
 
366
  </div>
367
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
368
  </div>
 
22
  import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
23
  import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
24
 
25
+ import FilePreview from './FilePreview';
26
+ import { ModelSelector } from '~/components/chat/ModelSelector';
27
+ import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  const TEXTAREA_MIN_HEIGHT = 76;
30
 
 
50
  enhancePrompt?: () => void;
51
  importChat?: (description: string, messages: Message[]) => Promise<void>;
52
  exportChat?: () => void;
53
+ uploadedFiles?: File[];
54
+ setUploadedFiles?: (files: File[]) => void;
55
+ imageDataList?: string[];
56
+ setImageDataList?: (dataList: string[]) => void;
57
  }
58
 
59
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
 
65
  showChat = true,
66
  chatStarted = false,
67
  isStreaming = false,
 
 
 
 
68
  model,
69
  setModel,
70
  provider,
71
  setProvider,
72
+ input = '',
73
+ enhancingPrompt,
74
  handleInputChange,
75
+ promptEnhanced,
76
  enhancePrompt,
77
+ sendMessage,
78
  handleStop,
79
  importChat,
80
  exportChat,
81
+ uploadedFiles = [],
82
+ setUploadedFiles,
83
+ imageDataList = [],
84
+ setImageDataList,
85
+ messages,
86
  },
87
  ref,
88
  ) => {
 
90
  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
91
  const [modelList, setModelList] = useState(MODEL_LIST);
92
  const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
93
+ const [isListening, setIsListening] = useState(false);
94
+ const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
95
+ const [transcript, setTranscript] = useState('');
96
 
97
+ console.log(transcript);
98
  useEffect(() => {
99
  // Load API keys from cookies on component mount
100
  try {
 
117
  initializeModelList().then((modelList) => {
118
  setModelList(modelList);
119
  });
120
+
121
+ if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
122
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
123
+ const recognition = new SpeechRecognition();
124
+ recognition.continuous = true;
125
+ recognition.interimResults = true;
126
+
127
+ recognition.onresult = (event) => {
128
+ const transcript = Array.from(event.results)
129
+ .map((result) => result[0])
130
+ .map((result) => result.transcript)
131
+ .join('');
132
+
133
+ setTranscript(transcript);
134
+
135
+ if (handleInputChange) {
136
+ const syntheticEvent = {
137
+ target: { value: transcript },
138
+ } as React.ChangeEvent<HTMLTextAreaElement>;
139
+ handleInputChange(syntheticEvent);
140
+ }
141
+ };
142
+
143
+ recognition.onerror = (event) => {
144
+ console.error('Speech recognition error:', event.error);
145
+ setIsListening(false);
146
+ };
147
+
148
+ setRecognition(recognition);
149
+ }
150
  }, []);
151
 
152
+ const startListening = () => {
153
+ if (recognition) {
154
+ recognition.start();
155
+ setIsListening(true);
156
+ }
157
+ };
158
+
159
+ const stopListening = () => {
160
+ if (recognition) {
161
+ recognition.stop();
162
+ setIsListening(false);
163
+ }
164
+ };
165
+
166
+ const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
167
+ if (sendMessage) {
168
+ sendMessage(event, messageInput);
169
+
170
+ if (recognition) {
171
+ recognition.abort(); // Stop current recognition
172
+ setTranscript(''); // Clear transcript
173
+ setIsListening(false);
174
+
175
+ // Clear the input by triggering handleInputChange with empty value
176
+ if (handleInputChange) {
177
+ const syntheticEvent = {
178
+ target: { value: '' },
179
+ } as React.ChangeEvent<HTMLTextAreaElement>;
180
+ handleInputChange(syntheticEvent);
181
+ }
182
+ }
183
+ }
184
+ };
185
+
186
  const updateApiKey = (provider: string, key: string) => {
187
  try {
188
  const updatedApiKeys = { ...apiKeys, [provider]: key };
 
200
  }
201
  };
202
 
203
+ const handleFileUpload = () => {
204
+ const input = document.createElement('input');
205
+ input.type = 'file';
206
+ input.accept = 'image/*';
207
+
208
+ input.onchange = async (e) => {
209
+ const file = (e.target as HTMLInputElement).files?.[0];
210
+
211
+ if (file) {
212
+ const reader = new FileReader();
213
+
214
+ reader.onload = (e) => {
215
+ const base64Image = e.target?.result as string;
216
+ setUploadedFiles?.([...uploadedFiles, file]);
217
+ setImageDataList?.([...imageDataList, base64Image]);
218
+ };
219
+ reader.readAsDataURL(file);
220
+ }
221
+ };
222
+
223
+ input.click();
224
+ };
225
+
226
+ const handlePaste = async (e: React.ClipboardEvent) => {
227
+ const items = e.clipboardData?.items;
228
+
229
+ if (!items) {
230
+ return;
231
+ }
232
+
233
+ for (const item of items) {
234
+ if (item.type.startsWith('image/')) {
235
+ e.preventDefault();
236
+
237
+ const file = item.getAsFile();
238
+
239
+ if (file) {
240
+ const reader = new FileReader();
241
+
242
+ reader.onload = (e) => {
243
+ const base64Image = e.target?.result as string;
244
+ setUploadedFiles?.([...uploadedFiles, file]);
245
+ setImageDataList?.([...imageDataList, base64Image]);
246
+ };
247
+ reader.readAsDataURL(file);
248
+ }
249
+
250
+ break;
251
+ }
252
+ }
253
+ };
254
+
255
  const baseChat = (
256
  <div
257
  ref={ref}
 
368
  )}
369
  </div>
370
  </div>
371
+ <FilePreview
372
+ files={uploadedFiles}
373
+ imageDataList={imageDataList}
374
+ onRemove={(index) => {
375
+ setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
376
+ setImageDataList?.(imageDataList.filter((_, i) => i !== index));
377
+ }}
378
+ />
379
  <div
380
  className={classNames(
381
  'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
 
383
  >
384
  <textarea
385
  ref={textareaRef}
386
+ className={classNames(
387
+ 'w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
388
+ 'transition-all duration-200',
389
+ 'hover:border-bolt-elements-focus',
390
+ )}
391
+ onDragEnter={(e) => {
392
+ e.preventDefault();
393
+ e.currentTarget.style.border = '2px solid #1488fc';
394
+ }}
395
+ onDragOver={(e) => {
396
+ e.preventDefault();
397
+ e.currentTarget.style.border = '2px solid #1488fc';
398
+ }}
399
+ onDragLeave={(e) => {
400
+ e.preventDefault();
401
+ e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
402
+ }}
403
+ onDrop={(e) => {
404
+ e.preventDefault();
405
+ e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
406
+
407
+ const files = Array.from(e.dataTransfer.files);
408
+ files.forEach((file) => {
409
+ if (file.type.startsWith('image/')) {
410
+ const reader = new FileReader();
411
+
412
+ reader.onload = (e) => {
413
+ const base64Image = e.target?.result as string;
414
+ setUploadedFiles?.([...uploadedFiles, file]);
415
+ setImageDataList?.([...imageDataList, base64Image]);
416
+ };
417
+ reader.readAsDataURL(file);
418
+ }
419
+ });
420
+ }}
421
  onKeyDown={(event) => {
422
  if (event.key === 'Enter') {
423
  if (event.shiftKey) {
 
426
 
427
  event.preventDefault();
428
 
429
+ if (isStreaming) {
430
+ handleStop?.();
431
+ return;
432
+ }
433
+
434
+ handleSendMessage?.(event);
435
  }
436
  }}
437
  value={input}
438
  onChange={(event) => {
439
  handleInputChange?.(event);
440
  }}
441
+ onPaste={handlePaste}
442
  style={{
443
  minHeight: TEXTAREA_MIN_HEIGHT,
444
  maxHeight: TEXTAREA_MAX_HEIGHT,
 
449
  <ClientOnly>
450
  {() => (
451
  <SendButton
452
+ show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
453
  isStreaming={isStreaming}
454
  onClick={(event) => {
455
  if (isStreaming) {
 
457
  return;
458
  }
459
 
460
+ if (input.length > 0 || uploadedFiles.length > 0) {
461
+ handleSendMessage?.(event);
462
+ }
463
  }}
464
  />
465
  )}
466
  </ClientOnly>
467
  <div className="flex justify-between items-center text-sm p-4 pt-2">
468
  <div className="flex gap-1 items-center">
469
+ <IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
470
+ <div className="i-ph:paperclip text-xl"></div>
471
+ </IconButton>
472
  <IconButton
473
  title="Enhance prompt"
474
  disabled={input.length === 0 || enhancingPrompt}
475
+ className={classNames(
476
+ 'transition-all',
477
+ enhancingPrompt ? 'opacity-100' : '',
478
+ promptEnhanced ? 'text-bolt-elements-item-contentAccent' : '',
479
+ promptEnhanced ? 'pr-1.5' : '',
480
+ promptEnhanced ? 'enabled:hover:bg-bolt-elements-item-backgroundAccent' : '',
481
+ )}
482
  onClick={() => enhancePrompt?.()}
483
  >
484
  {enhancingPrompt ? (
 
493
  </>
494
  )}
495
  </IconButton>
496
+
497
+ <SpeechRecognitionButton
498
+ isListening={isListening}
499
+ onStart={startListening}
500
+ onStop={stopListening}
501
+ disabled={isStreaming}
502
+ />
503
  {chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
504
  </div>
505
  {input.length > 3 ? (
 
514
  </div>
515
  </div>
516
  {!chatStarted && ImportButtons(importChat)}
517
+ {!chatStarted &&
518
+ ExamplePrompts((event, messageInput) => {
519
+ if (isStreaming) {
520
+ handleStop?.();
521
+ return;
522
+ }
523
+
524
+ handleSendMessage?.(event, messageInput);
525
+ })}
526
  </div>
527
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
528
  </div>
app/components/chat/Chat.client.tsx CHANGED
@@ -12,7 +12,6 @@ import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from
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';
16
  import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
17
  import { cubicEasingFn } from '~/utils/easings';
18
  import { createScopedLogger, renderLogger } from '~/utils/logger';
@@ -89,8 +88,10 @@ export const ChatImpl = memo(
89
  useShortcuts();
90
 
91
  const textareaRef = useRef<HTMLTextAreaElement>(null);
92
-
93
  const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
 
 
 
94
  const [model, setModel] = useState(() => {
95
  const savedModel = Cookies.get('selectedModel');
96
  return savedModel || DEFAULT_MODEL;
@@ -206,8 +207,6 @@ export const ChatImpl = memo(
206
  runAnimation();
207
 
208
  if (fileModifications !== undefined) {
209
- const diff = fileModificationsToHTML(fileModifications);
210
-
211
  /**
212
  * If we have file modifications we append a new user message manually since we have to prefix
213
  * the user input with the file modifications and we don't want the new user input to appear
@@ -215,7 +214,19 @@ export const ChatImpl = memo(
215
  * manually reset the input and we'd have to manually pass in file attachments. However, those
216
  * aren't relevant here.
217
  */
218
- append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
  /**
221
  * After sending a new message we reset all modifications since the model
@@ -223,12 +234,28 @@ export const ChatImpl = memo(
223
  */
224
  workbenchStore.resetAllFileModifications();
225
  } else {
226
- append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
 
 
 
 
 
 
 
 
 
 
 
 
227
  }
228
 
229
  setInput('');
230
  Cookies.remove(PROMPT_COOKIE_KEY);
231
 
 
 
 
 
232
  resetEnhancer();
233
 
234
  textareaRef.current?.blur();
@@ -321,6 +348,10 @@ export const ChatImpl = memo(
321
  apiKeys,
322
  );
323
  }}
 
 
 
 
324
  />
325
  );
326
  },
 
12
  import { description, useChatHistory } from '~/lib/persistence';
13
  import { chatStore } from '~/lib/stores/chat';
14
  import { workbenchStore } from '~/lib/stores/workbench';
 
15
  import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
16
  import { cubicEasingFn } from '~/utils/easings';
17
  import { createScopedLogger, renderLogger } from '~/utils/logger';
 
88
  useShortcuts();
89
 
90
  const textareaRef = useRef<HTMLTextAreaElement>(null);
 
91
  const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
92
+ const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
93
+ const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
94
+
95
  const [model, setModel] = useState(() => {
96
  const savedModel = Cookies.get('selectedModel');
97
  return savedModel || DEFAULT_MODEL;
 
207
  runAnimation();
208
 
209
  if (fileModifications !== undefined) {
 
 
210
  /**
211
  * If we have file modifications we append a new user message manually since we have to prefix
212
  * the user input with the file modifications and we don't want the new user input to appear
 
214
  * manually reset the input and we'd have to manually pass in file attachments. However, those
215
  * aren't relevant here.
216
  */
217
+ append({
218
+ role: 'user',
219
+ content: [
220
+ {
221
+ type: 'text',
222
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
223
+ },
224
+ ...imageDataList.map((imageData) => ({
225
+ type: 'image',
226
+ image: imageData,
227
+ })),
228
+ ] as any, // Type assertion to bypass compiler check
229
+ });
230
 
231
  /**
232
  * After sending a new message we reset all modifications since the model
 
234
  */
235
  workbenchStore.resetAllFileModifications();
236
  } else {
237
+ append({
238
+ role: 'user',
239
+ content: [
240
+ {
241
+ type: 'text',
242
+ text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
243
+ },
244
+ ...imageDataList.map((imageData) => ({
245
+ type: 'image',
246
+ image: imageData,
247
+ })),
248
+ ] as any, // Type assertion to bypass compiler check
249
+ });
250
  }
251
 
252
  setInput('');
253
  Cookies.remove(PROMPT_COOKIE_KEY);
254
 
255
+ // Add file cleanup here
256
+ setUploadedFiles([]);
257
+ setImageDataList([]);
258
+
259
  resetEnhancer();
260
 
261
  textareaRef.current?.blur();
 
348
  apiKeys,
349
  );
350
  }}
351
+ uploadedFiles={uploadedFiles}
352
+ setUploadedFiles={setUploadedFiles}
353
+ imageDataList={imageDataList}
354
+ setImageDataList={setImageDataList}
355
  />
356
  );
357
  },
app/components/chat/FilePreview.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ interface FilePreviewProps {
4
+ files: File[];
5
+ imageDataList: string[];
6
+ onRemove: (index: number) => void;
7
+ }
8
+
9
+ const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemove }) => {
10
+ if (!files || files.length === 0) {
11
+ return null;
12
+ }
13
+
14
+ return (
15
+ <div className="flex flex-row overflow-x-auto -mt-2">
16
+ {files.map((file, index) => (
17
+ <div key={file.name + file.size} className="mr-2 relative">
18
+ {imageDataList[index] && (
19
+ <div className="relative pt-4 pr-4">
20
+ <img src={imageDataList[index]} alt={file.name} className="max-h-20" />
21
+ <button
22
+ onClick={() => onRemove(index)}
23
+ className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
24
+ >
25
+ <div className="i-ph:x w-3 h-3 text-gray-200" />
26
+ </button>
27
+ </div>
28
+ )}
29
+ </div>
30
+ ))}
31
+ </div>
32
+ );
33
+ };
34
+
35
+ export default FilePreview;
app/components/chat/ModelSelector.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ProviderInfo } from '~/types/model';
2
+ import type { ModelInfo } from '~/utils/types';
3
+
4
+ interface ModelSelectorProps {
5
+ model?: string;
6
+ setModel?: (model: string) => void;
7
+ provider?: ProviderInfo;
8
+ setProvider?: (provider: ProviderInfo) => void;
9
+ modelList: ModelInfo[];
10
+ providerList: ProviderInfo[];
11
+ apiKeys: Record<string, string>;
12
+ }
13
+
14
+ export const ModelSelector = ({
15
+ model,
16
+ setModel,
17
+ provider,
18
+ setProvider,
19
+ modelList,
20
+ providerList,
21
+ }: ModelSelectorProps) => {
22
+ return (
23
+ <div className="mb-2 flex gap-2 flex-col sm:flex-row">
24
+ <select
25
+ value={provider?.name ?? ''}
26
+ onChange={(e) => {
27
+ const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
28
+
29
+ if (newProvider && setProvider) {
30
+ setProvider(newProvider);
31
+ }
32
+
33
+ const firstModel = [...modelList].find((m) => m.provider === e.target.value);
34
+
35
+ if (firstModel && setModel) {
36
+ setModel(firstModel.name);
37
+ }
38
+ }}
39
+ className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
40
+ >
41
+ {providerList.map((provider: ProviderInfo) => (
42
+ <option key={provider.name} value={provider.name}>
43
+ {provider.name}
44
+ </option>
45
+ ))}
46
+ </select>
47
+ <select
48
+ key={provider?.name}
49
+ value={model}
50
+ onChange={(e) => setModel?.(e.target.value)}
51
+ className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%]"
52
+ >
53
+ {[...modelList]
54
+ .filter((e) => e.provider == provider?.name && e.name)
55
+ .map((modelOption) => (
56
+ <option key={modelOption.name} value={modelOption.name}>
57
+ {modelOption.label}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ </div>
62
+ );
63
+ };
app/components/chat/SendButton.client.tsx CHANGED
@@ -4,11 +4,12 @@ interface SendButtonProps {
4
  show: boolean;
5
  isStreaming?: boolean;
6
  onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
 
7
  }
8
 
9
  const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
10
 
11
- export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
12
  return (
13
  <AnimatePresence>
14
  {show ? (
@@ -30,4 +31,4 @@ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
30
  ) : null}
31
  </AnimatePresence>
32
  );
33
- }
 
4
  show: boolean;
5
  isStreaming?: boolean;
6
  onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
7
+ onImagesSelected?: (images: File[]) => void;
8
  }
9
 
10
  const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
11
 
12
+ export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
13
  return (
14
  <AnimatePresence>
15
  {show ? (
 
31
  ) : null}
32
  </AnimatePresence>
33
  );
34
+ };
app/components/chat/SpeechRecognition.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconButton } from '~/components/ui/IconButton';
2
+ import { classNames } from '~/utils/classNames';
3
+ import React from 'react';
4
+
5
+ export const SpeechRecognitionButton = ({
6
+ isListening,
7
+ onStart,
8
+ onStop,
9
+ disabled,
10
+ }: {
11
+ isListening: boolean;
12
+ onStart: () => void;
13
+ onStop: () => void;
14
+ disabled: boolean;
15
+ }) => {
16
+ return (
17
+ <IconButton
18
+ title={isListening ? 'Stop listening' : 'Start speech recognition'}
19
+ disabled={disabled}
20
+ className={classNames('transition-all', {
21
+ 'text-bolt-elements-item-contentAccent': isListening,
22
+ })}
23
+ onClick={isListening ? onStop : onStart}
24
+ >
25
+ {isListening ? <div className="i-ph:microphone-slash text-xl" /> : <div className="i-ph:microphone text-xl" />}
26
+ </IconButton>
27
+ );
28
+ };
app/components/chat/UserMessage.tsx CHANGED
@@ -2,26 +2,52 @@
2
  * @ts-nocheck
3
  * Preventing TS checks with files presented in the video for a better presentation.
4
  */
5
- import { modificationsRegex } from '~/utils/diff';
6
  import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
7
  import { Markdown } from './Markdown';
8
 
9
  interface UserMessageProps {
10
- content: string;
11
  }
12
 
13
  export function UserMessage({ content }: UserMessageProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  return (
15
  <div className="overflow-hidden pt-[4px]">
16
- <Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
17
  </div>
18
  );
19
  }
20
 
21
  function sanitizeUserMessage(content: string) {
22
- return content
23
- .replace(modificationsRegex, '')
24
- .replace(MODEL_REGEX, 'Using: $1')
25
- .replace(PROVIDER_REGEX, ' ($1)\n\n')
26
- .trim();
27
  }
 
2
  * @ts-nocheck
3
  * Preventing TS checks with files presented in the video for a better presentation.
4
  */
 
5
  import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
6
  import { Markdown } from './Markdown';
7
 
8
  interface UserMessageProps {
9
+ content: string | Array<{ type: string; text?: string; image?: string }>;
10
  }
11
 
12
  export function UserMessage({ content }: UserMessageProps) {
13
+ if (Array.isArray(content)) {
14
+ const textItem = content.find((item) => item.type === 'text');
15
+ const textContent = sanitizeUserMessage(textItem?.text || '');
16
+ const images = content.filter((item) => item.type === 'image' && item.image);
17
+
18
+ return (
19
+ <div className="overflow-hidden pt-[4px]">
20
+ <div className="flex items-start gap-4">
21
+ <div className="flex-1">
22
+ <Markdown limitedMarkdown>{textContent}</Markdown>
23
+ </div>
24
+ {images.length > 0 && (
25
+ <div className="flex-shrink-0 w-[160px]">
26
+ {images.map((item, index) => (
27
+ <div key={index} className="relative">
28
+ <img
29
+ src={item.image}
30
+ alt={`Uploaded image ${index + 1}`}
31
+ className="w-full h-[160px] rounded-lg object-cover border border-bolt-elements-borderColor"
32
+ />
33
+ </div>
34
+ ))}
35
+ </div>
36
+ )}
37
+ </div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ const textContent = sanitizeUserMessage(content);
43
+
44
  return (
45
  <div className="overflow-hidden pt-[4px]">
46
+ <Markdown limitedMarkdown>{textContent}</Markdown>
47
  </div>
48
  );
49
  }
50
 
51
  function sanitizeUserMessage(content: string) {
52
+ return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
 
 
 
 
53
  }
app/components/header/Header.tsx CHANGED
@@ -24,17 +24,19 @@ export function Header() {
24
  <span className="i-bolt:logo-text?mask w-[46px] inline-block" />
25
  </a>
26
  </div>
27
- <span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
28
- <ClientOnly>{() => <ChatDescription />}</ClientOnly>
29
- </span>
30
- {chat.started && (
31
- <ClientOnly>
32
- {() => (
33
- <div className="mr-1">
34
- <HeaderActionButtons />
35
- </div>
36
- )}
37
- </ClientOnly>
 
 
38
  )}
39
  </header>
40
  );
 
24
  <span className="i-bolt:logo-text?mask w-[46px] inline-block" />
25
  </a>
26
  </div>
27
+ {chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
28
+ <>
29
+ <span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
30
+ <ClientOnly>{() => <ChatDescription />}</ClientOnly>
31
+ </span>
32
+ <ClientOnly>
33
+ {() => (
34
+ <div className="mr-1">
35
+ <HeaderActionButtons />
36
+ </div>
37
+ )}
38
+ </ClientOnly>
39
+ </>
40
  )}
41
  </header>
42
  );
app/components/header/HeaderActionButtons.client.tsx CHANGED
@@ -19,7 +19,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
19
  <div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
20
  <Button
21
  active={showChat}
22
- disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's needed
23
  onClick={() => {
24
  if (canHideChat) {
25
  chatStore.setKey('showChat', !showChat);
 
19
  <div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
20
  <Button
21
  active={showChat}
22
+ disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed
23
  onClick={() => {
24
  if (canHideChat) {
25
  chatStore.setKey('showChat', !showChat);
app/components/sidebar/HistoryItem.tsx CHANGED
@@ -1,6 +1,9 @@
 
 
1
  import * as Dialog from '@radix-ui/react-dialog';
2
  import { type ChatHistoryItem } from '~/lib/persistence';
3
  import WithTooltip from '~/components/ui/Tooltip';
 
4
 
5
  interface HistoryItemProps {
6
  item: ChatHistoryItem;
@@ -10,48 +13,115 @@ interface HistoryItemProps {
10
  }
11
 
12
  export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  return (
14
- <div className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1">
15
- <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
16
- {item.description}
17
- <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%">
18
- <div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
19
- <WithTooltip tooltip="Export chat">
20
- <button
21
- type="button"
22
- className="i-ph:download-simple scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
 
 
 
 
 
 
 
 
 
 
 
 
23
  onClick={(event) => {
24
  event.preventDefault();
25
  exportChat(item.id);
26
  }}
27
- title="Export chat"
28
  />
29
- </WithTooltip>
30
- {onDuplicate && (
31
- <WithTooltip tooltip="Duplicate chat">
32
- <button
33
- type="button"
34
- className="i-ph:copy scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
35
  onClick={() => onDuplicate?.(item.id)}
36
- title="Duplicate chat"
37
  />
38
- </WithTooltip>
39
- )}
40
- <Dialog.Trigger asChild>
41
- <WithTooltip tooltip="Delete chat">
42
- <button
43
- type="button"
44
- className="i-ph:trash scale-110 hover:text-bolt-elements-button-danger-text"
 
 
 
 
 
 
 
45
  onClick={(event) => {
46
  event.preventDefault();
47
  onDelete?.(event);
48
  }}
49
  />
50
- </WithTooltip>
51
- </Dialog.Trigger>
52
  </div>
53
- </div>
54
- </a>
55
  </div>
56
  );
57
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useParams } from '@remix-run/react';
2
+ import { classNames } from '~/utils/classNames';
3
  import * as Dialog from '@radix-ui/react-dialog';
4
  import { type ChatHistoryItem } from '~/lib/persistence';
5
  import WithTooltip from '~/components/ui/Tooltip';
6
+ import { useEditChatDescription } from '~/lib/hooks';
7
 
8
  interface HistoryItemProps {
9
  item: ChatHistoryItem;
 
13
  }
14
 
15
  export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
16
+ const { id: urlId } = useParams();
17
+ const isActiveChat = urlId === item.urlId;
18
+
19
+ const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
20
+ useEditChatDescription({
21
+ initialDescription: item.description,
22
+ customChatId: item.id,
23
+ syncWithGlobalStore: isActiveChat,
24
+ });
25
+
26
+ const renderDescriptionForm = (
27
+ <form onSubmit={handleSubmit} className="flex-1 flex items-center">
28
+ <input
29
+ type="text"
30
+ className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
31
+ autoFocus
32
+ value={currentDescription}
33
+ onChange={handleChange}
34
+ onBlur={handleBlur}
35
+ onKeyDown={handleKeyDown}
36
+ />
37
+ <button
38
+ type="submit"
39
+ className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
40
+ onMouseDown={handleSubmit}
41
+ />
42
+ </form>
43
+ );
44
+
45
  return (
46
+ <div
47
+ className={classNames(
48
+ 'group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1',
49
+ { '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
50
+ )}
51
+ >
52
+ {editing ? (
53
+ renderDescriptionForm
54
+ ) : (
55
+ <a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
56
+ {currentDescription}
57
+ <div
58
+ className={classNames(
59
+ '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-22 group-hover:from-99%',
60
+ { 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat },
61
+ )}
62
+ >
63
+ <div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
64
+ <ChatActionButton
65
+ toolTipContent="Export chat"
66
+ icon="i-ph:download-simple"
67
  onClick={(event) => {
68
  event.preventDefault();
69
  exportChat(item.id);
70
  }}
 
71
  />
72
+ {onDuplicate && (
73
+ <ChatActionButton
74
+ toolTipContent="Duplicate chat"
75
+ icon="i-ph:copy"
 
 
76
  onClick={() => onDuplicate?.(item.id)}
 
77
  />
78
+ )}
79
+ <ChatActionButton
80
+ toolTipContent="Rename chat"
81
+ icon="i-ph:pencil-fill"
82
+ onClick={(event) => {
83
+ event.preventDefault();
84
+ toggleEditMode();
85
+ }}
86
+ />
87
+ <Dialog.Trigger asChild>
88
+ <ChatActionButton
89
+ toolTipContent="Delete chat"
90
+ icon="i-ph:trash"
91
+ className="[&&]:hover:text-bolt-elements-button-danger-text"
92
  onClick={(event) => {
93
  event.preventDefault();
94
  onDelete?.(event);
95
  }}
96
  />
97
+ </Dialog.Trigger>
98
+ </div>
99
  </div>
100
+ </a>
101
+ )}
102
  </div>
103
  );
104
  }
105
+
106
+ const ChatActionButton = ({
107
+ toolTipContent,
108
+ icon,
109
+ className,
110
+ onClick,
111
+ }: {
112
+ toolTipContent: string;
113
+ icon: string;
114
+ className?: string;
115
+ onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
116
+ btnTitle?: string;
117
+ }) => {
118
+ return (
119
+ <WithTooltip tooltip={toolTipContent}>
120
+ <button
121
+ type="button"
122
+ className={`scale-110 mr-2 hover:text-bolt-elements-item-contentAccent ${icon} ${className ? className : ''}`}
123
+ onClick={onClick}
124
+ />
125
+ </WithTooltip>
126
+ );
127
+ };
app/components/sidebar/Menu.client.tsx CHANGED
@@ -33,7 +33,7 @@ const menuVariants = {
33
 
34
  type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
35
 
36
- export function Menu() {
37
  const { duplicateCurrentChat, exportChat } = useChatHistory();
38
  const menuRef = useRef<HTMLDivElement>(null);
39
  const [list, setList] = useState<ChatHistoryItem[]>([]);
@@ -206,4 +206,4 @@ export function Menu() {
206
  </div>
207
  </motion.div>
208
  );
209
- }
 
33
 
34
  type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
35
 
36
+ export const Menu = () => {
37
  const { duplicateCurrentChat, exportChat } = useChatHistory();
38
  const menuRef = useRef<HTMLDivElement>(null);
39
  const [list, setList] = useState<ChatHistoryItem[]>([]);
 
206
  </div>
207
  </motion.div>
208
  );
209
+ };
app/components/workbench/Preview.tsx CHANGED
@@ -4,11 +4,16 @@ import { IconButton } from '~/components/ui/IconButton';
4
  import { workbenchStore } from '~/lib/stores/workbench';
5
  import { PortDropdown } from './PortDropdown';
6
 
 
 
7
  export const Preview = memo(() => {
8
  const iframeRef = useRef<HTMLIFrameElement>(null);
 
9
  const inputRef = useRef<HTMLInputElement>(null);
 
10
  const [activePreviewIndex, setActivePreviewIndex] = useState(0);
11
  const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
 
12
  const hasSelectedPreview = useRef(false);
13
  const previews = useStore(workbenchStore.previews);
14
  const activePreview = previews[activePreviewIndex];
@@ -16,6 +21,23 @@ export const Preview = memo(() => {
16
  const [url, setUrl] = useState('');
17
  const [iframeUrl, setIframeUrl] = useState<string | undefined>();
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  useEffect(() => {
20
  if (!activePreview) {
21
  setUrl('');
@@ -25,10 +47,9 @@ export const Preview = memo(() => {
25
  }
26
 
27
  const { baseUrl } = activePreview;
28
-
29
  setUrl(baseUrl);
30
  setIframeUrl(baseUrl);
31
- }, [activePreview, iframeUrl]);
32
 
33
  const validateUrl = useCallback(
34
  (value: string) => {
@@ -56,14 +77,13 @@ export const Preview = memo(() => {
56
  [],
57
  );
58
 
59
- // when previews change, display the lowest port if user hasn't selected a preview
60
  useEffect(() => {
61
  if (previews.length > 1 && !hasSelectedPreview.current) {
62
  const minPortIndex = previews.reduce(findMinPortIndex, 0);
63
-
64
  setActivePreviewIndex(minPortIndex);
65
  }
66
- }, [previews]);
67
 
68
  const reloadPreview = () => {
69
  if (iframeRef.current) {
@@ -71,13 +91,134 @@ export const Preview = memo(() => {
71
  }
72
  };
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  return (
75
- <div className="w-full h-full flex flex-col">
76
  {isPortDropdownOpen && (
77
  <div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
78
  )}
79
  <div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
80
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
 
81
  <div
82
  className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
83
  focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
@@ -101,6 +242,7 @@ export const Preview = memo(() => {
101
  }}
102
  />
103
  </div>
 
104
  {previews.length > 1 && (
105
  <PortDropdown
106
  activePreviewIndex={activePreviewIndex}
@@ -111,13 +253,93 @@ export const Preview = memo(() => {
111
  previews={previews}
112
  />
113
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
- <div className="flex-1 border-t border-bolt-elements-borderColor">
116
- {activePreview ? (
117
- <iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
118
- ) : (
119
- <div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
120
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  </div>
122
  </div>
123
  );
 
4
  import { workbenchStore } from '~/lib/stores/workbench';
5
  import { PortDropdown } from './PortDropdown';
6
 
7
+ type ResizeSide = 'left' | 'right' | null;
8
+
9
  export const Preview = memo(() => {
10
  const iframeRef = useRef<HTMLIFrameElement>(null);
11
+ const containerRef = useRef<HTMLDivElement>(null);
12
  const inputRef = useRef<HTMLInputElement>(null);
13
+
14
  const [activePreviewIndex, setActivePreviewIndex] = useState(0);
15
  const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
16
+ const [isFullscreen, setIsFullscreen] = useState(false);
17
  const hasSelectedPreview = useRef(false);
18
  const previews = useStore(workbenchStore.previews);
19
  const activePreview = previews[activePreviewIndex];
 
21
  const [url, setUrl] = useState('');
22
  const [iframeUrl, setIframeUrl] = useState<string | undefined>();
23
 
24
+ // Toggle between responsive mode and device mode
25
+ const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
26
+
27
+ // Use percentage for width
28
+ const [widthPercent, setWidthPercent] = useState<number>(37.5); // 375px assuming 1000px window width initially
29
+
30
+ const resizingState = useRef({
31
+ isResizing: false,
32
+ side: null as ResizeSide,
33
+ startX: 0,
34
+ startWidthPercent: 37.5,
35
+ windowWidth: window.innerWidth,
36
+ });
37
+
38
+ // Define the scaling factor
39
+ const SCALING_FACTOR = 2; // Adjust this value to increase/decrease sensitivity
40
+
41
  useEffect(() => {
42
  if (!activePreview) {
43
  setUrl('');
 
47
  }
48
 
49
  const { baseUrl } = activePreview;
 
50
  setUrl(baseUrl);
51
  setIframeUrl(baseUrl);
52
+ }, [activePreview]);
53
 
54
  const validateUrl = useCallback(
55
  (value: string) => {
 
77
  [],
78
  );
79
 
80
+ // When previews change, display the lowest port if user hasn't selected a preview
81
  useEffect(() => {
82
  if (previews.length > 1 && !hasSelectedPreview.current) {
83
  const minPortIndex = previews.reduce(findMinPortIndex, 0);
 
84
  setActivePreviewIndex(minPortIndex);
85
  }
86
+ }, [previews, findMinPortIndex]);
87
 
88
  const reloadPreview = () => {
89
  if (iframeRef.current) {
 
91
  }
92
  };
93
 
94
+ const toggleFullscreen = async () => {
95
+ if (!isFullscreen && containerRef.current) {
96
+ await containerRef.current.requestFullscreen();
97
+ } else if (document.fullscreenElement) {
98
+ await document.exitFullscreen();
99
+ }
100
+ };
101
+
102
+ useEffect(() => {
103
+ const handleFullscreenChange = () => {
104
+ setIsFullscreen(!!document.fullscreenElement);
105
+ };
106
+
107
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
108
+
109
+ return () => {
110
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
111
+ };
112
+ }, []);
113
+
114
+ const toggleDeviceMode = () => {
115
+ setIsDeviceModeOn((prev) => !prev);
116
+ };
117
+
118
+ const startResizing = (e: React.MouseEvent, side: ResizeSide) => {
119
+ if (!isDeviceModeOn) {
120
+ return;
121
+ }
122
+
123
+ // Prevent text selection
124
+ document.body.style.userSelect = 'none';
125
+
126
+ resizingState.current.isResizing = true;
127
+ resizingState.current.side = side;
128
+ resizingState.current.startX = e.clientX;
129
+ resizingState.current.startWidthPercent = widthPercent;
130
+ resizingState.current.windowWidth = window.innerWidth;
131
+
132
+ document.addEventListener('mousemove', onMouseMove);
133
+ document.addEventListener('mouseup', onMouseUp);
134
+
135
+ e.preventDefault(); // Prevent any text selection on mousedown
136
+ };
137
+
138
+ const onMouseMove = (e: MouseEvent) => {
139
+ if (!resizingState.current.isResizing) {
140
+ return;
141
+ }
142
+
143
+ const dx = e.clientX - resizingState.current.startX;
144
+ const windowWidth = resizingState.current.windowWidth;
145
+
146
+ // Apply scaling factor to increase sensitivity
147
+ const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR;
148
+
149
+ let newWidthPercent = resizingState.current.startWidthPercent;
150
+
151
+ if (resizingState.current.side === 'right') {
152
+ newWidthPercent = resizingState.current.startWidthPercent + dxPercent;
153
+ } else if (resizingState.current.side === 'left') {
154
+ newWidthPercent = resizingState.current.startWidthPercent - dxPercent;
155
+ }
156
+
157
+ // Clamp the width between 10% and 90%
158
+ newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
159
+
160
+ setWidthPercent(newWidthPercent);
161
+ };
162
+
163
+ const onMouseUp = () => {
164
+ resizingState.current.isResizing = false;
165
+ resizingState.current.side = null;
166
+ document.removeEventListener('mousemove', onMouseMove);
167
+ document.removeEventListener('mouseup', onMouseUp);
168
+
169
+ // Restore text selection
170
+ document.body.style.userSelect = '';
171
+ };
172
+
173
+ // Handle window resize to ensure widthPercent remains valid
174
+ useEffect(() => {
175
+ const handleWindowResize = () => {
176
+ /*
177
+ * Optional: Adjust widthPercent if necessary
178
+ * For now, since widthPercent is relative, no action is needed
179
+ */
180
+ };
181
+
182
+ window.addEventListener('resize', handleWindowResize);
183
+
184
+ return () => {
185
+ window.removeEventListener('resize', handleWindowResize);
186
+ };
187
+ }, []);
188
+
189
+ // A small helper component for the handle's "grip" icon
190
+ const GripIcon = () => (
191
+ <div
192
+ style={{
193
+ display: 'flex',
194
+ justifyContent: 'center',
195
+ alignItems: 'center',
196
+ height: '100%',
197
+ pointerEvents: 'none',
198
+ }}
199
+ >
200
+ <div
201
+ style={{
202
+ color: 'rgba(0,0,0,0.5)',
203
+ fontSize: '10px',
204
+ lineHeight: '5px',
205
+ userSelect: 'none',
206
+ marginLeft: '1px',
207
+ }}
208
+ >
209
+ ••• •••
210
+ </div>
211
+ </div>
212
+ );
213
+
214
  return (
215
+ <div ref={containerRef} className="w-full h-full flex flex-col relative">
216
  {isPortDropdownOpen && (
217
  <div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
218
  )}
219
  <div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
220
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
221
+
222
  <div
223
  className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
224
  focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
 
242
  }}
243
  />
244
  </div>
245
+
246
  {previews.length > 1 && (
247
  <PortDropdown
248
  activePreviewIndex={activePreviewIndex}
 
253
  previews={previews}
254
  />
255
  )}
256
+
257
+ {/* Device mode toggle button */}
258
+ <IconButton
259
+ icon="i-ph:devices"
260
+ onClick={toggleDeviceMode}
261
+ title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
262
+ />
263
+
264
+ {/* Fullscreen toggle button */}
265
+ <IconButton
266
+ icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
267
+ onClick={toggleFullscreen}
268
+ title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
269
+ />
270
  </div>
271
+
272
+ <div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
273
+ <div
274
+ style={{
275
+ width: isDeviceModeOn ? `${widthPercent}%` : '100%',
276
+ height: '100%', // Always full height
277
+ overflow: 'visible',
278
+ background: '#fff',
279
+ position: 'relative',
280
+ display: 'flex',
281
+ }}
282
+ >
283
+ {activePreview ? (
284
+ <iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} allowFullScreen />
285
+ ) : (
286
+ <div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
287
+ )}
288
+
289
+ {isDeviceModeOn && (
290
+ <>
291
+ {/* Left handle */}
292
+ <div
293
+ onMouseDown={(e) => startResizing(e, 'left')}
294
+ style={{
295
+ position: 'absolute',
296
+ top: 0,
297
+ left: 0,
298
+ width: '15px',
299
+ marginLeft: '-15px',
300
+ height: '100%',
301
+ cursor: 'ew-resize',
302
+ background: 'rgba(255,255,255,.2)',
303
+ display: 'flex',
304
+ alignItems: 'center',
305
+ justifyContent: 'center',
306
+ transition: 'background 0.2s',
307
+ userSelect: 'none',
308
+ }}
309
+ onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
310
+ onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
311
+ title="Drag to resize width"
312
+ >
313
+ <GripIcon />
314
+ </div>
315
+
316
+ {/* Right handle */}
317
+ <div
318
+ onMouseDown={(e) => startResizing(e, 'right')}
319
+ style={{
320
+ position: 'absolute',
321
+ top: 0,
322
+ right: 0,
323
+ width: '15px',
324
+ marginRight: '-15px',
325
+ height: '100%',
326
+ cursor: 'ew-resize',
327
+ background: 'rgba(255,255,255,.2)',
328
+ display: 'flex',
329
+ alignItems: 'center',
330
+ justifyContent: 'center',
331
+ transition: 'background 0.2s',
332
+ userSelect: 'none',
333
+ }}
334
+ onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
335
+ onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
336
+ title="Drag to resize width"
337
+ >
338
+ <GripIcon />
339
+ </div>
340
+ </>
341
+ )}
342
+ </div>
343
  </div>
344
  </div>
345
  );
app/lib/.server/llm/model.ts CHANGED
@@ -128,7 +128,12 @@ export function getXAIModel(apiKey: OptionalApiKey, model: string) {
128
  }
129
 
130
  export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
131
- const apiKey = getAPIKey(env, provider, apiKeys);
 
 
 
 
 
132
  const baseURL = getBaseURL(env, provider);
133
 
134
  switch (provider) {
 
128
  }
129
 
130
  export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
131
+ /*
132
+ * let apiKey; // Declare first
133
+ * let baseURL;
134
+ */
135
+
136
+ const apiKey = getAPIKey(env, provider, apiKeys); // Then assign
137
  const baseURL = getBaseURL(env, provider);
138
 
139
  switch (provider) {
app/lib/.server/llm/stream-text.ts CHANGED
@@ -23,16 +23,37 @@ export type Messages = Message[];
23
  export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
24
 
25
  function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
26
- // Extract model
27
- const modelMatch = message.content.match(MODEL_REGEX);
 
 
 
 
 
 
 
 
 
28
  const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
29
 
30
- // Extract provider
31
- const providerMatch = message.content.match(PROVIDER_REGEX);
 
 
32
  const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name;
33
 
34
- // Remove model and provider lines from content
35
- const cleanedContent = message.content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').trim();
 
 
 
 
 
 
 
 
 
 
36
 
37
  return { model, provider, content: cleanedContent };
38
  }
 
23
  export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
24
 
25
  function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
26
+ const textContent = Array.isArray(message.content)
27
+ ? message.content.find((item) => item.type === 'text')?.text || ''
28
+ : message.content;
29
+
30
+ const modelMatch = textContent.match(MODEL_REGEX);
31
+ const providerMatch = textContent.match(PROVIDER_REGEX);
32
+
33
+ /*
34
+ * Extract model
35
+ * const modelMatch = message.content.match(MODEL_REGEX);
36
+ */
37
  const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
38
 
39
+ /*
40
+ * Extract provider
41
+ * const providerMatch = message.content.match(PROVIDER_REGEX);
42
+ */
43
  const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name;
44
 
45
+ const cleanedContent = Array.isArray(message.content)
46
+ ? message.content.map((item) => {
47
+ if (item.type === 'text') {
48
+ return {
49
+ type: 'text',
50
+ text: item.text?.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, ''),
51
+ };
52
+ }
53
+
54
+ return item; // Preserve image_url and other types as is
55
+ })
56
+ : textContent.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
57
 
58
  return { model, provider, content: cleanedContent };
59
  }
app/lib/hooks/index.ts CHANGED
@@ -2,4 +2,5 @@ export * from './useMessageParser';
2
  export * from './usePromptEnhancer';
3
  export * from './useShortcuts';
4
  export * from './useSnapScroll';
 
5
  export { default } from './useViewport';
 
2
  export * from './usePromptEnhancer';
3
  export * from './useShortcuts';
4
  export * from './useSnapScroll';
5
+ export * from './useEditChatDescription';
6
  export { default } from './useViewport';
app/lib/hooks/useEditChatDescription.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import { toast } from 'react-toastify';
4
+ import {
5
+ chatId as chatIdStore,
6
+ description as descriptionStore,
7
+ db,
8
+ updateChatDescription,
9
+ getMessages,
10
+ } from '~/lib/persistence';
11
+
12
+ interface EditChatDescriptionOptions {
13
+ initialDescription?: string;
14
+ customChatId?: string;
15
+ syncWithGlobalStore?: boolean;
16
+ }
17
+
18
+ type EditChatDescriptionHook = {
19
+ editing: boolean;
20
+ handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
21
+ handleBlur: () => Promise<void>;
22
+ handleSubmit: (event: React.FormEvent) => Promise<void>;
23
+ handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
24
+ currentDescription: string;
25
+ toggleEditMode: () => void;
26
+ };
27
+
28
+ /**
29
+ * Hook to manage the state and behavior for editing chat descriptions.
30
+ *
31
+ * Offers functions to:
32
+ * - Switch between edit and view modes.
33
+ * - Manage input changes, blur, and form submission events.
34
+ * - Save updates to IndexedDB and optionally to the global application state.
35
+ *
36
+ * @param {Object} options
37
+ * @param {string} options.initialDescription - The current chat description.
38
+ * @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
39
+ * @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
40
+ * @returns {EditChatDescriptionHook} Methods and state for managing description edits.
41
+ */
42
+ export function useEditChatDescription({
43
+ initialDescription = descriptionStore.get()!,
44
+ customChatId,
45
+ syncWithGlobalStore,
46
+ }: EditChatDescriptionOptions): EditChatDescriptionHook {
47
+ const chatIdFromStore = useStore(chatIdStore);
48
+ const [editing, setEditing] = useState(false);
49
+ const [currentDescription, setCurrentDescription] = useState(initialDescription);
50
+
51
+ const [chatId, setChatId] = useState<string>();
52
+
53
+ useEffect(() => {
54
+ setChatId(customChatId || chatIdFromStore);
55
+ }, [customChatId, chatIdFromStore]);
56
+ useEffect(() => {
57
+ setCurrentDescription(initialDescription);
58
+ }, [initialDescription]);
59
+
60
+ const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
61
+
62
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
63
+ setCurrentDescription(e.target.value);
64
+ }, []);
65
+
66
+ const fetchLatestDescription = useCallback(async () => {
67
+ if (!db || !chatId) {
68
+ return initialDescription;
69
+ }
70
+
71
+ try {
72
+ const chat = await getMessages(db, chatId);
73
+ return chat?.description || initialDescription;
74
+ } catch (error) {
75
+ console.error('Failed to fetch latest description:', error);
76
+ return initialDescription;
77
+ }
78
+ }, [db, chatId, initialDescription]);
79
+
80
+ const handleBlur = useCallback(async () => {
81
+ const latestDescription = await fetchLatestDescription();
82
+ setCurrentDescription(latestDescription);
83
+ toggleEditMode();
84
+ }, [fetchLatestDescription, toggleEditMode]);
85
+
86
+ const isValidDescription = useCallback((desc: string): boolean => {
87
+ const trimmedDesc = desc.trim();
88
+
89
+ if (trimmedDesc === initialDescription) {
90
+ toggleEditMode();
91
+ return false; // No change, skip validation
92
+ }
93
+
94
+ const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
95
+ const characterValid = /^[a-zA-Z0-9\s]+$/.test(trimmedDesc);
96
+
97
+ if (!lengthValid) {
98
+ toast.error('Description must be between 1 and 100 characters.');
99
+ return false;
100
+ }
101
+
102
+ if (!characterValid) {
103
+ toast.error('Description can only contain alphanumeric characters and spaces.');
104
+ return false;
105
+ }
106
+
107
+ return true;
108
+ }, []);
109
+
110
+ const handleSubmit = useCallback(
111
+ async (event: React.FormEvent) => {
112
+ event.preventDefault();
113
+
114
+ if (!isValidDescription(currentDescription)) {
115
+ return;
116
+ }
117
+
118
+ try {
119
+ if (!db) {
120
+ toast.error('Chat persistence is not available');
121
+ return;
122
+ }
123
+
124
+ if (!chatId) {
125
+ toast.error('Chat Id is not available');
126
+ return;
127
+ }
128
+
129
+ await updateChatDescription(db, chatId, currentDescription);
130
+
131
+ if (syncWithGlobalStore) {
132
+ descriptionStore.set(currentDescription);
133
+ }
134
+
135
+ toast.success('Chat description updated successfully');
136
+ } catch (error) {
137
+ toast.error('Failed to update chat description: ' + (error as Error).message);
138
+ }
139
+
140
+ toggleEditMode();
141
+ },
142
+ [currentDescription, db, chatId, initialDescription, customChatId],
143
+ );
144
+
145
+ const handleKeyDown = useCallback(
146
+ async (e: React.KeyboardEvent<HTMLInputElement>) => {
147
+ if (e.key === 'Escape') {
148
+ await handleBlur();
149
+ }
150
+ },
151
+ [handleBlur],
152
+ );
153
+
154
+ return {
155
+ editing,
156
+ handleChange,
157
+ handleBlur,
158
+ handleSubmit,
159
+ handleKeyDown,
160
+ currentDescription,
161
+ toggleEditMode,
162
+ };
163
+ }
app/lib/persistence/ChatDescription.client.tsx CHANGED
@@ -1,6 +1,68 @@
1
  import { useStore } from '@nanostores/react';
2
- import { description } from './useChatHistory';
 
 
 
3
 
4
  export function ChatDescription() {
5
- return useStore(description);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  }
 
1
  import { useStore } from '@nanostores/react';
2
+ import { TooltipProvider } from '@radix-ui/react-tooltip';
3
+ import WithTooltip from '~/components/ui/Tooltip';
4
+ import { useEditChatDescription } from '~/lib/hooks';
5
+ import { description as descriptionStore } from '~/lib/persistence';
6
 
7
  export function ChatDescription() {
8
+ const initialDescription = useStore(descriptionStore)!;
9
+
10
+ const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
11
+ useEditChatDescription({
12
+ initialDescription,
13
+ syncWithGlobalStore: true,
14
+ });
15
+
16
+ if (!initialDescription) {
17
+ // doing this to prevent showing edit button until chat description is set
18
+ return null;
19
+ }
20
+
21
+ return (
22
+ <div className="flex items-center justify-center">
23
+ {editing ? (
24
+ <form onSubmit={handleSubmit} className="flex items-center justify-center">
25
+ <input
26
+ type="text"
27
+ className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2 w-fit"
28
+ autoFocus
29
+ value={currentDescription}
30
+ onChange={handleChange}
31
+ onBlur={handleBlur}
32
+ onKeyDown={handleKeyDown}
33
+ style={{ width: `${Math.max(currentDescription.length * 8, 100)}px` }}
34
+ />
35
+ <TooltipProvider>
36
+ <WithTooltip tooltip="Save title">
37
+ <div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent">
38
+ <button
39
+ type="submit"
40
+ className="i-ph:check-bold scale-110 hover:text-bolt-elements-item-contentAccent"
41
+ onMouseDown={handleSubmit}
42
+ />
43
+ </div>
44
+ </WithTooltip>
45
+ </TooltipProvider>
46
+ </form>
47
+ ) : (
48
+ <>
49
+ {currentDescription}
50
+ <TooltipProvider>
51
+ <WithTooltip tooltip="Rename chat">
52
+ <div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent ml-2">
53
+ <button
54
+ type="button"
55
+ className="i-ph:pencil-fill scale-110 hover:text-bolt-elements-item-contentAccent"
56
+ onClick={(event) => {
57
+ event.preventDefault();
58
+ toggleEditMode();
59
+ }}
60
+ />
61
+ </div>
62
+ </WithTooltip>
63
+ </TooltipProvider>
64
+ </>
65
+ )}
66
+ </div>
67
+ );
68
  }
app/lib/persistence/db.ts CHANGED
@@ -52,17 +52,23 @@ export async function setMessages(
52
  messages: Message[],
53
  urlId?: string,
54
  description?: string,
 
55
  ): Promise<void> {
56
  return new Promise((resolve, reject) => {
57
  const transaction = db.transaction('chats', 'readwrite');
58
  const store = transaction.objectStore('chats');
59
 
 
 
 
 
 
60
  const request = store.put({
61
  id,
62
  messages,
63
  urlId,
64
  description,
65
- timestamp: new Date().toISOString(),
66
  });
67
 
68
  request.onsuccess = () => resolve();
@@ -212,3 +218,17 @@ export async function createChatFromMessages(
212
 
213
  return newUrlId; // Return the urlId instead of id for navigation
214
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  messages: Message[],
53
  urlId?: string,
54
  description?: string,
55
+ timestamp?: string,
56
  ): Promise<void> {
57
  return new Promise((resolve, reject) => {
58
  const transaction = db.transaction('chats', 'readwrite');
59
  const store = transaction.objectStore('chats');
60
 
61
+ if (timestamp && isNaN(Date.parse(timestamp))) {
62
+ reject(new Error('Invalid timestamp'));
63
+ return;
64
+ }
65
+
66
  const request = store.put({
67
  id,
68
  messages,
69
  urlId,
70
  description,
71
+ timestamp: timestamp ?? new Date().toISOString(),
72
  });
73
 
74
  request.onsuccess = () => resolve();
 
218
 
219
  return newUrlId; // Return the urlId instead of id for navigation
220
  }
221
+
222
+ export async function updateChatDescription(db: IDBDatabase, id: string, description: string): Promise<void> {
223
+ const chat = await getMessages(db, id);
224
+
225
+ if (!chat) {
226
+ throw new Error('Chat not found');
227
+ }
228
+
229
+ if (!description.trim()) {
230
+ throw new Error('Description cannot be empty');
231
+ }
232
+
233
+ await setMessages(db, id, chat.messages, chat.urlId, description, chat.timestamp);
234
+ }
app/lib/runtime/action-runner.ts CHANGED
@@ -100,6 +100,10 @@ export class ActionRunner {
100
  .catch((error) => {
101
  console.error('Action failed:', error);
102
  });
 
 
 
 
103
  }
104
 
105
  async #executeAction(actionId: string, isStreaming: boolean = false) {
 
100
  .catch((error) => {
101
  console.error('Action failed:', error);
102
  });
103
+
104
+ await this.#currentExecutionPromise;
105
+
106
+ return;
107
  }
108
 
109
  async #executeAction(actionId: string, isStreaming: boolean = false) {
app/lib/stores/files.ts CHANGED
@@ -212,9 +212,5 @@ function isBinaryFile(buffer: Uint8Array | undefined) {
212
  * array buffer.
213
  */
214
  function convertToBuffer(view: Uint8Array): Buffer {
215
- const buffer = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
216
-
217
- Object.setPrototypeOf(buffer, Buffer.prototype);
218
-
219
- return buffer as Buffer;
220
  }
 
212
  * array buffer.
213
  */
214
  function convertToBuffer(view: Uint8Array): Buffer {
215
+ return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
 
 
 
 
216
  }
app/routes/api.chat.ts CHANGED
@@ -29,8 +29,9 @@ function parseCookies(cookieHeader:string) {
29
  }
30
 
31
  async function chatAction({ context, request }: ActionFunctionArgs) {
32
- const { messages } = await request.json<{
33
  messages: Messages;
 
34
  }>();
35
 
36
  const cookieHeader = request.headers.get('Cookie');
 
29
  }
30
 
31
  async function chatAction({ context, request }: ActionFunctionArgs) {
32
+ const { messages, model } = await request.json<{
33
  messages: Messages;
34
+ model: string;
35
  }>();
36
 
37
  const cookieHeader = request.headers.get('Cookie');
app/routes/api.enhancer.ts CHANGED
@@ -44,8 +44,9 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
44
  content:
45
  `[Model: ${model}]\n\n[Provider: ${providerName}]\n\n` +
46
  stripIndents`
47
- You are a professional prompt engineer specializing in crafting precise, effective prompts.
48
  Your task is to enhance prompts by making them more specific, actionable, and effective.
 
49
  I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
50
 
51
  For valid prompts:
@@ -55,12 +56,14 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
55
  - Maintain the core intent
56
  - Ensure the prompt is self-contained
57
  - Use professional language
 
58
  For invalid or unclear prompts:
59
  - Respond with a clear, professional guidance message
60
  - Keep responses concise and actionable
61
  - Maintain a helpful, constructive tone
62
  - Focus on what the user should provide
63
  - Use a standard template for consistency
 
64
  IMPORTANT: Your response must ONLY contain the enhanced prompt text.
65
  Do not include any explanations, metadata, or wrapper tags.
66
 
 
44
  content:
45
  `[Model: ${model}]\n\n[Provider: ${providerName}]\n\n` +
46
  stripIndents`
47
+ You are a professional prompt engineer specializing in crafting precise, effective prompts.
48
  Your task is to enhance prompts by making them more specific, actionable, and effective.
49
+
50
  I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
51
 
52
  For valid prompts:
 
56
  - Maintain the core intent
57
  - Ensure the prompt is self-contained
58
  - Use professional language
59
+
60
  For invalid or unclear prompts:
61
  - Respond with a clear, professional guidance message
62
  - Keep responses concise and actionable
63
  - Maintain a helpful, constructive tone
64
  - Focus on what the user should provide
65
  - Use a standard template for consistency
66
+
67
  IMPORTANT: Your response must ONLY contain the enhanced prompt text.
68
  Do not include any explanations, metadata, or wrapper tags.
69
 
app/types/global.d.ts CHANGED
@@ -1,3 +1,5 @@
1
  interface Window {
2
  showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
 
 
3
  }
 
1
  interface Window {
2
  showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
3
+ webkitSpeechRecognition: typeof SpeechRecognition;
4
+ SpeechRecognition: typeof SpeechRecognition;
5
  }
package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -101,6 +101,7 @@
101
  "@cloudflare/workers-types": "^4.20241127.0",
102
  "@remix-run/dev": "^2.15.0",
103
  "@types/diff": "^5.2.3",
 
104
  "@types/file-saver": "^2.0.7",
105
  "@types/js-cookie": "^3.0.6",
106
  "@types/react": "^18.3.12",
 
101
  "@cloudflare/workers-types": "^4.20241127.0",
102
  "@remix-run/dev": "^2.15.0",
103
  "@types/diff": "^5.2.3",
104
+ "@types/dom-speech-recognition": "^0.0.4",
105
  "@types/file-saver": "^2.0.7",
106
  "@types/js-cookie": "^3.0.6",
107
  "@types/react": "^18.3.12",
pnpm-lock.yaml CHANGED
@@ -222,6 +222,9 @@ importers:
222
  '@types/diff':
223
  specifier: ^5.2.3
224
  version: 5.2.3
 
 
 
225
  '@types/file-saver':
226
  specifier: ^2.0.7
227
  version: 2.0.7
@@ -2039,6 +2042,9 @@ packages:
2039
  '@types/[email protected]':
2040
  resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
2041
 
 
 
 
2042
  '@types/[email protected]':
2043
  resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==}
2044
 
@@ -7464,6 +7470,8 @@ snapshots:
7464
 
7465
  '@types/[email protected]': {}
7466
 
 
 
7467
  '@types/[email protected]':
7468
  dependencies:
7469
  '@types/estree': 1.0.6
@@ -7812,7 +7820,7 @@ snapshots:
7812
  '@babel/plugin-syntax-typescript': 7.25.9(@babel/[email protected])
7813
  '@vanilla-extract/babel-plugin-debug-ids': 1.1.0
7814
  '@vanilla-extract/css': 1.16.1
7815
- esbuild: 0.17.6
7816
  eval: 0.1.8
7817
  find-up: 5.0.0
7818
  javascript-stringify: 2.1.0
 
222
  '@types/diff':
223
  specifier: ^5.2.3
224
  version: 5.2.3
225
+ '@types/dom-speech-recognition':
226
+ specifier: ^0.0.4
227
+ version: 0.0.4
228
  '@types/file-saver':
229
  specifier: ^2.0.7
230
  version: 2.0.7
 
2042
  '@types/[email protected]':
2043
  resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
2044
 
2045
+ '@types/[email protected]':
2046
+ resolution: {integrity: sha512-zf2GwV/G6TdaLwpLDcGTIkHnXf8JEf/viMux+khqKQKDa8/8BAUtXXZS563GnvJ4Fg0PBLGAaFf2GekEVSZ6GQ==}
2047
+
2048
  '@types/[email protected]':
2049
  resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==}
2050
 
 
7470
 
7471
  '@types/[email protected]': {}
7472
 
7473
+ '@types/[email protected]': {}
7474
+
7475
  '@types/[email protected]':
7476
  dependencies:
7477
  '@types/estree': 1.0.6
 
7820
  '@babel/plugin-syntax-typescript': 7.25.9(@babel/[email protected])
7821
  '@vanilla-extract/babel-plugin-debug-ids': 1.1.0
7822
  '@vanilla-extract/css': 1.16.1
7823
+ esbuild: 0.17.19
7824
  eval: 0.1.8
7825
  find-up: 5.0.0
7826
  javascript-stringify: 2.1.0
tsconfig.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "compilerOptions": {
3
  "lib": ["DOM", "DOM.Iterable", "ESNext"],
4
- "types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01"],
5
  "isolatedModules": true,
6
  "esModuleInterop": true,
7
  "jsx": "react-jsx",
 
1
  {
2
  "compilerOptions": {
3
  "lib": ["DOM", "DOM.Iterable", "ESNext"],
4
+ "types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01", "@types/dom-speech-recognition"],
5
  "isolatedModules": true,
6
  "esModuleInterop": true,
7
  "jsx": "react-jsx",
vite.config.ts CHANGED
@@ -19,8 +19,7 @@ export default defineConfig((config) => {
19
  future: {
20
  v3_fetcherPersist: true,
21
  v3_relativeSplatPath: true,
22
- v3_throwAbortReason: true,
23
- v3_lazyRouteDiscovery: true,
24
  },
25
  }),
26
  UnoCSS(),
 
19
  future: {
20
  v3_fetcherPersist: true,
21
  v3_relativeSplatPath: true,
22
+ v3_throwAbortReason: true
 
23
  },
24
  }),
25
  UnoCSS(),