codacus commited on
Commit
39398ea
·
unverified ·
2 Parent(s): ed9e18d 1774bf6

Merge branch 'main' into Folder-import-refinement

Browse files
Files changed (45) hide show
  1. .github/ISSUE_TEMPLATE/bug_report.yml +10 -0
  2. .github/workflows/stale.yml +3 -3
  3. .husky/pre-commit +13 -4
  4. README.md +5 -4
  5. app/components/chat/Artifact.tsx +24 -2
  6. app/components/chat/BaseChat.tsx +223 -57
  7. app/components/chat/Chat.client.tsx +37 -6
  8. app/components/chat/FilePreview.tsx +35 -0
  9. app/components/chat/GitCloneButton.tsx +103 -0
  10. app/components/chat/ImportFolderButton.tsx +0 -1
  11. app/components/chat/ModelSelector.tsx +63 -0
  12. app/components/chat/SendButton.client.tsx +3 -2
  13. app/components/chat/SpeechRecognition.tsx +28 -0
  14. app/components/chat/UserMessage.tsx +34 -8
  15. app/components/chat/chatExportAndImport/ImportButtons.tsx +1 -1
  16. app/components/header/Header.tsx +13 -11
  17. app/components/header/HeaderActionButtons.client.tsx +1 -1
  18. app/components/sidebar/HistoryItem.tsx +98 -28
  19. app/components/sidebar/Menu.client.tsx +2 -2
  20. app/components/workbench/Preview.tsx +234 -12
  21. app/lib/.server/llm/api-key.ts +1 -1
  22. app/lib/.server/llm/model.ts +6 -1
  23. app/lib/.server/llm/stream-text.ts +40 -17
  24. app/lib/hooks/index.ts +1 -0
  25. app/lib/hooks/useEditChatDescription.ts +163 -0
  26. app/lib/hooks/useGit.ts +287 -0
  27. app/lib/persistence/ChatDescription.client.tsx +64 -2
  28. app/lib/persistence/db.ts +21 -1
  29. app/lib/runtime/__snapshots__/message-parser.spec.ts.snap +18 -0
  30. app/lib/runtime/action-runner.ts +4 -0
  31. app/lib/runtime/message-parser.spec.ts +5 -1
  32. app/lib/runtime/message-parser.ts +2 -0
  33. app/lib/stores/files.ts +1 -5
  34. app/lib/stores/workbench.ts +3 -1
  35. app/routes/api.chat.ts +6 -9
  36. app/routes/api.enhancer.ts +4 -1
  37. app/types/artifact.ts +1 -0
  38. app/types/global.d.ts +2 -0
  39. app/types/model.ts +1 -1
  40. app/utils/constants.ts +82 -2
  41. package-lock.json +0 -0
  42. package.json +4 -1
  43. pnpm-lock.yaml +0 -0
  44. tsconfig.json +1 -1
  45. vite.config.ts +2 -3
.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,15 +2,24 @@
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! 🚀"
8
- exit 1
 
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
16
 
 
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
+ echo "Running typecheck..."
9
+ which pnpm
10
+
11
  if ! pnpm typecheck; then
12
+ echo "❌ Type checking failed! Please review TypeScript types."
13
+ echo "Once you're done, don't forget to add your changes to the commit! 🚀"
14
+ echo "Typecheck exit code: $?"
15
+ exit 1
16
  fi
17
 
18
+ echo "Running lint..."
19
  if ! pnpm lint; then
20
+ echo "❌ Linting failed! 'pnpm lint:fix' will help you fix the easy ones."
21
  echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
22
+ echo "lint exit code: $?"
23
  exit 1
24
  fi
25
 
README.md CHANGED
@@ -34,23 +34,24 @@ https://thinktank.ottomator.ai
34
  - ✅ Ability to revert code to earlier version (@wonderwhy-er)
35
  - ✅ Cohere Integration (@hasanraiyan)
36
  - ✅ Dynamic model max token length (@hasanraiyan)
 
37
  - ✅ Prompt caching (@SujalXplores)
38
  - ✅ Load local projects into the app (@wonderwhy-er)
39
  - ✅ Together Integration (@mouimet-infinisoft)
40
  - ✅ Mobile friendly (@qwikode)
41
  - ✅ Better prompt enhancing (@SujalXplores)
42
- - **HIGH PRIORITY** - ALMOST DONE - Attach images to prompts (@atrokhym)
43
  - ⬜ **HIGH PRIORITY** - Prevent Bolt from rewriting files as often (file locking and diffs)
44
  - ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
45
  - ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
46
- - ⬜ Azure Open AI API Integration
47
- - ⬜ Perplexity Integration
48
- - ⬜ Vertex AI Integration
49
  - ⬜ Deploy directly to Vercel/Netlify/other similar platforms
50
  - ⬜ Have LLM plan the project in a MD file for better results/transparency
51
  - ⬜ VSCode Integration with git-like confirmations
52
  - ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
53
  - ⬜ Voice prompting
 
 
 
54
 
55
  ## Bolt.new: AI-Powered Full-Stack Web Development in the Browser
56
 
 
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/Artifact.tsx CHANGED
@@ -28,6 +28,7 @@ interface ArtifactProps {
28
  export const Artifact = memo(({ messageId }: ArtifactProps) => {
29
  const userToggledActions = useRef(false);
30
  const [showActions, setShowActions] = useState(false);
 
31
 
32
  const artifacts = useStore(workbenchStore.artifacts);
33
  const artifact = artifacts[messageId];
@@ -47,6 +48,14 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
47
  if (actions.length && !showActions && !userToggledActions.current) {
48
  setShowActions(true);
49
  }
 
 
 
 
 
 
 
 
50
  }, [actions]);
51
 
52
  return (
@@ -59,6 +68,18 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
59
  workbenchStore.showWorkbench.set(!showWorkbench);
60
  }}
61
  >
 
 
 
 
 
 
 
 
 
 
 
 
62
  <div className="px-5 p-3.5 w-full text-left">
63
  <div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
64
  <div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
@@ -66,7 +87,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
66
  </button>
67
  <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
68
  <AnimatePresence>
69
- {actions.length && (
70
  <motion.button
71
  initial={{ width: 0 }}
72
  animate={{ width: 'auto' }}
@@ -83,7 +104,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
83
  </AnimatePresence>
84
  </div>
85
  <AnimatePresence>
86
- {showActions && actions.length > 0 && (
87
  <motion.div
88
  className="actions"
89
  initial={{ height: 0 }}
@@ -92,6 +113,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
92
  transition={{ duration: 0.15 }}
93
  >
94
  <div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
 
95
  <div className="p-5 text-left bg-bolt-elements-actions-background">
96
  <ActionList actions={actions} />
97
  </div>
 
28
  export const Artifact = memo(({ messageId }: ArtifactProps) => {
29
  const userToggledActions = useRef(false);
30
  const [showActions, setShowActions] = useState(false);
31
+ const [allActionFinished, setAllActionFinished] = useState(false);
32
 
33
  const artifacts = useStore(workbenchStore.artifacts);
34
  const artifact = artifacts[messageId];
 
48
  if (actions.length && !showActions && !userToggledActions.current) {
49
  setShowActions(true);
50
  }
51
+
52
+ if (actions.length !== 0 && artifact.type === 'bundled') {
53
+ const finished = !actions.find((action) => action.status !== 'complete');
54
+
55
+ if (allActionFinished !== finished) {
56
+ setAllActionFinished(finished);
57
+ }
58
+ }
59
  }, [actions]);
60
 
61
  return (
 
68
  workbenchStore.showWorkbench.set(!showWorkbench);
69
  }}
70
  >
71
+ {artifact.type == 'bundled' && (
72
+ <>
73
+ <div className="p-4">
74
+ {allActionFinished ? (
75
+ <div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
76
+ ) : (
77
+ <div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
78
+ )}
79
+ </div>
80
+ <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
81
+ </>
82
+ )}
83
  <div className="px-5 p-3.5 w-full text-left">
84
  <div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
85
  <div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
 
87
  </button>
88
  <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
89
  <AnimatePresence>
90
+ {actions.length && artifact.type !== 'bundled' && (
91
  <motion.button
92
  initial={{ width: 0 }}
93
  animate={{ width: 'auto' }}
 
104
  </AnimatePresence>
105
  </div>
106
  <AnimatePresence>
107
+ {artifact.type !== 'bundled' && showActions && actions.length > 0 && (
108
  <motion.div
109
  className="actions"
110
  initial={{ height: 0 }}
 
113
  transition={{ duration: 0.15 }}
114
  >
115
  <div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
116
+
117
  <div className="p-5 text-left bg-bolt-elements-actions-background">
118
  <ActionList actions={actions} />
119
  </div>
app/components/chat/BaseChat.tsx CHANGED
@@ -21,45 +21,11 @@ import type { ProviderInfo } from '~/utils/types';
21
  import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
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 +51,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 +66,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 +91,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 +118,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 +201,58 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
159
  }
160
  };
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  const baseChat = (
163
  <div
164
  ref={ref}
@@ -275,7 +369,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 +384,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 +427,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 +450,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 +458,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 +494,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 ? (
@@ -361,8 +514,21 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
361
  </div>
362
  </div>
363
  </div>
364
- {!chatStarted && ImportButtons(importChat)}
365
- {!chatStarted && ExamplePrompts(sendMessage)}
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  </div>
367
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
368
  </div>
 
21
  import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
22
  import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
23
  import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
24
+ import GitCloneButton from './GitCloneButton';
25
 
26
+ import FilePreview from './FilePreview';
27
+ import { ModelSelector } from '~/components/chat/ModelSelector';
28
+ import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  const TEXTAREA_MIN_HEIGHT = 76;
31
 
 
51
  enhancePrompt?: () => void;
52
  importChat?: (description: string, messages: Message[]) => Promise<void>;
53
  exportChat?: () => void;
54
+ uploadedFiles?: File[];
55
+ setUploadedFiles?: (files: File[]) => void;
56
+ imageDataList?: string[];
57
+ setImageDataList?: (dataList: string[]) => void;
58
  }
59
 
60
  export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
 
66
  showChat = true,
67
  chatStarted = false,
68
  isStreaming = false,
 
 
 
 
69
  model,
70
  setModel,
71
  provider,
72
  setProvider,
73
+ input = '',
74
+ enhancingPrompt,
75
  handleInputChange,
76
+ promptEnhanced,
77
  enhancePrompt,
78
+ sendMessage,
79
  handleStop,
80
  importChat,
81
  exportChat,
82
+ uploadedFiles = [],
83
+ setUploadedFiles,
84
+ imageDataList = [],
85
+ setImageDataList,
86
+ messages,
87
  },
88
  ref,
89
  ) => {
 
91
  const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
92
  const [modelList, setModelList] = useState(MODEL_LIST);
93
  const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
94
+ const [isListening, setIsListening] = useState(false);
95
+ const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
96
+ const [transcript, setTranscript] = useState('');
97
 
98
+ console.log(transcript);
99
  useEffect(() => {
100
  // Load API keys from cookies on component mount
101
  try {
 
118
  initializeModelList().then((modelList) => {
119
  setModelList(modelList);
120
  });
121
+
122
+ if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
123
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
124
+ const recognition = new SpeechRecognition();
125
+ recognition.continuous = true;
126
+ recognition.interimResults = true;
127
+
128
+ recognition.onresult = (event) => {
129
+ const transcript = Array.from(event.results)
130
+ .map((result) => result[0])
131
+ .map((result) => result.transcript)
132
+ .join('');
133
+
134
+ setTranscript(transcript);
135
+
136
+ if (handleInputChange) {
137
+ const syntheticEvent = {
138
+ target: { value: transcript },
139
+ } as React.ChangeEvent<HTMLTextAreaElement>;
140
+ handleInputChange(syntheticEvent);
141
+ }
142
+ };
143
+
144
+ recognition.onerror = (event) => {
145
+ console.error('Speech recognition error:', event.error);
146
+ setIsListening(false);
147
+ };
148
+
149
+ setRecognition(recognition);
150
+ }
151
  }, []);
152
 
153
+ const startListening = () => {
154
+ if (recognition) {
155
+ recognition.start();
156
+ setIsListening(true);
157
+ }
158
+ };
159
+
160
+ const stopListening = () => {
161
+ if (recognition) {
162
+ recognition.stop();
163
+ setIsListening(false);
164
+ }
165
+ };
166
+
167
+ const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
168
+ if (sendMessage) {
169
+ sendMessage(event, messageInput);
170
+
171
+ if (recognition) {
172
+ recognition.abort(); // Stop current recognition
173
+ setTranscript(''); // Clear transcript
174
+ setIsListening(false);
175
+
176
+ // Clear the input by triggering handleInputChange with empty value
177
+ if (handleInputChange) {
178
+ const syntheticEvent = {
179
+ target: { value: '' },
180
+ } as React.ChangeEvent<HTMLTextAreaElement>;
181
+ handleInputChange(syntheticEvent);
182
+ }
183
+ }
184
+ }
185
+ };
186
+
187
  const updateApiKey = (provider: string, key: string) => {
188
  try {
189
  const updatedApiKeys = { ...apiKeys, [provider]: key };
 
201
  }
202
  };
203
 
204
+ const handleFileUpload = () => {
205
+ const input = document.createElement('input');
206
+ input.type = 'file';
207
+ input.accept = 'image/*';
208
+
209
+ input.onchange = async (e) => {
210
+ const file = (e.target as HTMLInputElement).files?.[0];
211
+
212
+ if (file) {
213
+ const reader = new FileReader();
214
+
215
+ reader.onload = (e) => {
216
+ const base64Image = e.target?.result as string;
217
+ setUploadedFiles?.([...uploadedFiles, file]);
218
+ setImageDataList?.([...imageDataList, base64Image]);
219
+ };
220
+ reader.readAsDataURL(file);
221
+ }
222
+ };
223
+
224
+ input.click();
225
+ };
226
+
227
+ const handlePaste = async (e: React.ClipboardEvent) => {
228
+ const items = e.clipboardData?.items;
229
+
230
+ if (!items) {
231
+ return;
232
+ }
233
+
234
+ for (const item of items) {
235
+ if (item.type.startsWith('image/')) {
236
+ e.preventDefault();
237
+
238
+ const file = item.getAsFile();
239
+
240
+ if (file) {
241
+ const reader = new FileReader();
242
+
243
+ reader.onload = (e) => {
244
+ const base64Image = e.target?.result as string;
245
+ setUploadedFiles?.([...uploadedFiles, file]);
246
+ setImageDataList?.([...imageDataList, base64Image]);
247
+ };
248
+ reader.readAsDataURL(file);
249
+ }
250
+
251
+ break;
252
+ }
253
+ }
254
+ };
255
+
256
  const baseChat = (
257
  <div
258
  ref={ref}
 
369
  )}
370
  </div>
371
  </div>
372
+ <FilePreview
373
+ files={uploadedFiles}
374
+ imageDataList={imageDataList}
375
+ onRemove={(index) => {
376
+ setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
377
+ setImageDataList?.(imageDataList.filter((_, i) => i !== index));
378
+ }}
379
+ />
380
  <div
381
  className={classNames(
382
  'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
 
384
  >
385
  <textarea
386
  ref={textareaRef}
387
+ className={classNames(
388
+ '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',
389
+ 'transition-all duration-200',
390
+ 'hover:border-bolt-elements-focus',
391
+ )}
392
+ onDragEnter={(e) => {
393
+ e.preventDefault();
394
+ e.currentTarget.style.border = '2px solid #1488fc';
395
+ }}
396
+ onDragOver={(e) => {
397
+ e.preventDefault();
398
+ e.currentTarget.style.border = '2px solid #1488fc';
399
+ }}
400
+ onDragLeave={(e) => {
401
+ e.preventDefault();
402
+ e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
403
+ }}
404
+ onDrop={(e) => {
405
+ e.preventDefault();
406
+ e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
407
+
408
+ const files = Array.from(e.dataTransfer.files);
409
+ files.forEach((file) => {
410
+ if (file.type.startsWith('image/')) {
411
+ const reader = new FileReader();
412
+
413
+ reader.onload = (e) => {
414
+ const base64Image = e.target?.result as string;
415
+ setUploadedFiles?.([...uploadedFiles, file]);
416
+ setImageDataList?.([...imageDataList, base64Image]);
417
+ };
418
+ reader.readAsDataURL(file);
419
+ }
420
+ });
421
+ }}
422
  onKeyDown={(event) => {
423
  if (event.key === 'Enter') {
424
  if (event.shiftKey) {
 
427
 
428
  event.preventDefault();
429
 
430
+ if (isStreaming) {
431
+ handleStop?.();
432
+ return;
433
+ }
434
+
435
+ handleSendMessage?.(event);
436
  }
437
  }}
438
  value={input}
439
  onChange={(event) => {
440
  handleInputChange?.(event);
441
  }}
442
+ onPaste={handlePaste}
443
  style={{
444
  minHeight: TEXTAREA_MIN_HEIGHT,
445
  maxHeight: TEXTAREA_MAX_HEIGHT,
 
450
  <ClientOnly>
451
  {() => (
452
  <SendButton
453
+ show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
454
  isStreaming={isStreaming}
455
  onClick={(event) => {
456
  if (isStreaming) {
 
458
  return;
459
  }
460
 
461
+ if (input.length > 0 || uploadedFiles.length > 0) {
462
+ handleSendMessage?.(event);
463
+ }
464
  }}
465
  />
466
  )}
467
  </ClientOnly>
468
  <div className="flex justify-between items-center text-sm p-4 pt-2">
469
  <div className="flex gap-1 items-center">
470
+ <IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
471
+ <div className="i-ph:paperclip text-xl"></div>
472
+ </IconButton>
473
  <IconButton
474
  title="Enhance prompt"
475
  disabled={input.length === 0 || enhancingPrompt}
476
+ className={classNames(
477
+ 'transition-all',
478
+ enhancingPrompt ? 'opacity-100' : '',
479
+ promptEnhanced ? 'text-bolt-elements-item-contentAccent' : '',
480
+ promptEnhanced ? 'pr-1.5' : '',
481
+ promptEnhanced ? 'enabled:hover:bg-bolt-elements-item-backgroundAccent' : '',
482
+ )}
483
  onClick={() => enhancePrompt?.()}
484
  >
485
  {enhancingPrompt ? (
 
494
  </>
495
  )}
496
  </IconButton>
497
+
498
+ <SpeechRecognitionButton
499
+ isListening={isListening}
500
+ onStart={startListening}
501
+ onStop={stopListening}
502
+ disabled={isStreaming}
503
+ />
504
  {chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
505
  </div>
506
  {input.length > 3 ? (
 
514
  </div>
515
  </div>
516
  </div>
517
+ {!chatStarted && (
518
+ <div className="flex justify-center gap-2">
519
+ {ImportButtons(importChat)}
520
+ <GitCloneButton importChat={importChat} />
521
+ </div>
522
+ )}
523
+ {!chatStarted &&
524
+ ExamplePrompts((event, messageInput) => {
525
+ if (isStreaming) {
526
+ handleStop?.();
527
+ return;
528
+ }
529
+
530
+ handleSendMessage?.(event, messageInput);
531
+ })}
532
  </div>
533
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
534
  </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/GitCloneButton.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ignore from 'ignore';
2
+ import { useGit } from '~/lib/hooks/useGit';
3
+ import type { Message } from 'ai';
4
+ import WithTooltip from '~/components/ui/Tooltip';
5
+
6
+ const IGNORE_PATTERNS = [
7
+ 'node_modules/**',
8
+ '.git/**',
9
+ '.github/**',
10
+ '.vscode/**',
11
+ '**/*.jpg',
12
+ '**/*.jpeg',
13
+ '**/*.png',
14
+ 'dist/**',
15
+ 'build/**',
16
+ '.next/**',
17
+ 'coverage/**',
18
+ '.cache/**',
19
+ '.vscode/**',
20
+ '.idea/**',
21
+ '**/*.log',
22
+ '**/.DS_Store',
23
+ '**/npm-debug.log*',
24
+ '**/yarn-debug.log*',
25
+ '**/yarn-error.log*',
26
+ '**/*lock.json',
27
+ '**/*lock.yaml',
28
+ ];
29
+
30
+ const ig = ignore().add(IGNORE_PATTERNS);
31
+ const generateId = () => Math.random().toString(36).substring(2, 15);
32
+
33
+ interface GitCloneButtonProps {
34
+ className?: string;
35
+ importChat?: (description: string, messages: Message[]) => Promise<void>;
36
+ }
37
+
38
+ export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
39
+ const { ready, gitClone } = useGit();
40
+ const onClick = async (_e: any) => {
41
+ if (!ready) {
42
+ return;
43
+ }
44
+
45
+ const repoUrl = prompt('Enter the Git url');
46
+
47
+ if (repoUrl) {
48
+ const { workdir, data } = await gitClone(repoUrl);
49
+
50
+ if (importChat) {
51
+ const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
52
+ console.log(filePaths);
53
+
54
+ const textDecoder = new TextDecoder('utf-8');
55
+ const message: Message = {
56
+ role: 'assistant',
57
+ content: `Cloning the repo ${repoUrl} into ${workdir}
58
+ <boltArtifact id="imported-files" title="Git Cloned Files" type="bundled" >
59
+ ${filePaths
60
+ .map((filePath) => {
61
+ const { data: content, encoding } = data[filePath];
62
+
63
+ if (encoding === 'utf8') {
64
+ return `<boltAction type="file" filePath="${filePath}">
65
+ ${content}
66
+ </boltAction>`;
67
+ } else if (content instanceof Uint8Array) {
68
+ return `<boltAction type="file" filePath="${filePath}">
69
+ ${textDecoder.decode(content)}
70
+ </boltAction>`;
71
+ } else {
72
+ return '';
73
+ }
74
+ })
75
+ .join('\n')}
76
+ </boltArtifact>`,
77
+ id: generateId(),
78
+ createdAt: new Date(),
79
+ };
80
+ console.log(JSON.stringify(message));
81
+
82
+ importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, [message]);
83
+
84
+ // console.log(files);
85
+ }
86
+ }
87
+ };
88
+
89
+ return (
90
+ <WithTooltip tooltip="Clone A Git Repo">
91
+ <button
92
+ onClick={(e) => {
93
+ onClick(e);
94
+ }}
95
+ title="Clone A Git Repo"
96
+ 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"
97
+ >
98
+ <span className="i-ph:git-branch" />
99
+ Clone A Git Repo
100
+ </button>
101
+ </WithTooltip>
102
+ );
103
+ }
app/components/chat/ImportFolderButton.tsx CHANGED
@@ -21,7 +21,6 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
21
  );
22
  return;
23
  }
24
-
25
  const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
26
  setIsLoading(true);
27
  const loadingToast = toast.loading(`Importing ${folderName}...`);
 
21
  );
22
  return;
23
  }
 
24
  const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
25
  setIsLoading(true);
26
  const loadingToast = toast.loading(`Importing ${folderName}...`);
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/chat/chatExportAndImport/ImportButtons.tsx CHANGED
@@ -5,7 +5,7 @@ import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
5
 
6
  export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
7
  return (
8
- <div className="flex flex-col items-center justify-center flex-1 p-4">
9
  <input
10
  type="file"
11
  id="chat-import"
 
5
 
6
  export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
7
  return (
8
+ <div className="flex flex-col items-center justify-center w-auto">
9
  <input
10
  type="file"
11
  id="chat-import"
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/api-key.ts CHANGED
@@ -51,7 +51,7 @@ export function getAPIKey(cloudflareEnv: Env, provider: string, userApiKeys?: Re
51
  export function getBaseURL(cloudflareEnv: Env, provider: string) {
52
  switch (provider) {
53
  case 'Together':
54
- return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL;
55
  case 'OpenAILike':
56
  return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL;
57
  case 'LMStudio':
 
51
  export function getBaseURL(cloudflareEnv: Env, provider: string) {
52
  switch (provider) {
53
  case 'Together':
54
+ return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL || 'https://api.together.xyz/v1';
55
  case 'OpenAILike':
56
  return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL;
57
  case 'LMStudio':
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
@@ -1,11 +1,8 @@
1
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
2
- // @ts-nocheck – TODO: Provider proper types
3
-
4
  import { convertToCoreMessages, streamText as _streamText } from 'ai';
5
  import { getModel } from '~/lib/.server/llm/model';
6
  import { MAX_TOKENS } from './constants';
7
  import { getSystemPrompt } from './prompts';
8
- import { DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_LIST, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
9
 
10
  interface ToolResult<Name extends string, Args, Result> {
11
  toolCallId: string;
@@ -26,24 +23,50 @@ export type Messages = Message[];
26
  export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
27
 
28
  function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
29
- // Extract model
30
- const modelMatch = message.content.match(MODEL_REGEX);
31
- const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
32
 
33
- // Extract provider
34
- const providerMatch = message.content.match(PROVIDER_REGEX);
35
- const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER;
36
 
37
- // Remove model and provider lines from content
38
- const cleanedContent = message.content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  return { model, provider, content: cleanedContent };
41
  }
42
 
43
- export function streamText(messages: Messages, env: Env, options?: StreamingOptions, apiKeys?: Record<string, string>) {
 
 
 
 
 
44
  let currentModel = DEFAULT_MODEL;
45
- let currentProvider = DEFAULT_PROVIDER;
46
-
47
  const processedMessages = messages.map((message) => {
48
  if (message.role === 'user') {
49
  const { model, provider, content } = extractPropertiesFromMessage(message);
@@ -65,10 +88,10 @@ export function streamText(messages: Messages, env: Env, options?: StreamingOpti
65
  const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
66
 
67
  return _streamText({
68
- model: getModel(currentProvider, currentModel, env, apiKeys),
69
  system: getSystemPrompt(),
70
  maxTokens: dynamicMaxTokens,
71
- messages: convertToCoreMessages(processedMessages),
72
  ...options,
73
  });
74
  }
 
 
 
 
1
  import { convertToCoreMessages, streamText as _streamText } from 'ai';
2
  import { getModel } from '~/lib/.server/llm/model';
3
  import { MAX_TOKENS } from './constants';
4
  import { getSystemPrompt } from './prompts';
5
+ import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelList, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
6
 
7
  interface ToolResult<Name extends string, Args, Result> {
8
  toolCallId: string;
 
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
  }
60
 
61
+ export async function streamText(
62
+ messages: Messages,
63
+ env: Env,
64
+ options?: StreamingOptions,
65
+ apiKeys?: Record<string, string>,
66
+ ) {
67
  let currentModel = DEFAULT_MODEL;
68
+ let currentProvider = DEFAULT_PROVIDER.name;
69
+ const MODEL_LIST = await getModelList(apiKeys || {});
70
  const processedMessages = messages.map((message) => {
71
  if (message.role === 'user') {
72
  const { model, provider, content } = extractPropertiesFromMessage(message);
 
88
  const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
89
 
90
  return _streamText({
91
+ model: getModel(currentProvider, currentModel, env, apiKeys) as any,
92
  system: getSystemPrompt(),
93
  maxTokens: dynamicMaxTokens,
94
+ messages: convertToCoreMessages(processedMessages as any),
95
  ...options,
96
  });
97
  }
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/hooks/useGit.ts ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { WebContainer } from '@webcontainer/api';
2
+ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
3
+ import { webcontainer as webcontainerPromise } from '~/lib/webcontainer';
4
+ import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
5
+ import http from 'isomorphic-git/http/web';
6
+ import Cookies from 'js-cookie';
7
+ import { toast } from 'react-toastify';
8
+
9
+ const lookupSavedPassword = (url: string) => {
10
+ const domain = url.split('/')[2];
11
+ const gitCreds = Cookies.get(`git:${domain}`);
12
+
13
+ if (!gitCreds) {
14
+ return null;
15
+ }
16
+
17
+ try {
18
+ const { username, password } = JSON.parse(gitCreds || '{}');
19
+ return { username, password };
20
+ } catch (error) {
21
+ console.log(`Failed to parse Git Cookie ${error}`);
22
+ return null;
23
+ }
24
+ };
25
+
26
+ const saveGitAuth = (url: string, auth: GitAuth) => {
27
+ const domain = url.split('/')[2];
28
+ Cookies.set(`git:${domain}`, JSON.stringify(auth));
29
+ };
30
+
31
+ export function useGit() {
32
+ const [ready, setReady] = useState(false);
33
+ const [webcontainer, setWebcontainer] = useState<WebContainer>();
34
+ const [fs, setFs] = useState<PromiseFsClient>();
35
+ const fileData = useRef<Record<string, { data: any; encoding?: string }>>({});
36
+ useEffect(() => {
37
+ webcontainerPromise.then((container) => {
38
+ fileData.current = {};
39
+ setWebcontainer(container);
40
+ setFs(getFs(container, fileData));
41
+ setReady(true);
42
+ });
43
+ }, []);
44
+
45
+ const gitClone = useCallback(
46
+ async (url: string) => {
47
+ if (!webcontainer || !fs || !ready) {
48
+ throw 'Webcontainer not initialized';
49
+ }
50
+
51
+ fileData.current = {};
52
+ await git.clone({
53
+ fs,
54
+ http,
55
+ dir: webcontainer.workdir,
56
+ url,
57
+ depth: 1,
58
+ singleBranch: true,
59
+ corsProxy: 'https://cors.isomorphic-git.org',
60
+ onAuth: (url) => {
61
+ // let domain=url.split("/")[2]
62
+
63
+ let auth = lookupSavedPassword(url);
64
+
65
+ if (auth) {
66
+ return auth;
67
+ }
68
+
69
+ if (confirm('This repo is password protected. Ready to enter a username & password?')) {
70
+ auth = {
71
+ username: prompt('Enter username'),
72
+ password: prompt('Enter password'),
73
+ };
74
+ return auth;
75
+ } else {
76
+ return { cancel: true };
77
+ }
78
+ },
79
+ onAuthFailure: (url, _auth) => {
80
+ toast.error(`Error Authenticating with ${url.split('/')[2]}`);
81
+ },
82
+ onAuthSuccess: (url, auth) => {
83
+ saveGitAuth(url, auth);
84
+ },
85
+ });
86
+
87
+ const data: Record<string, { data: any; encoding?: string }> = {};
88
+
89
+ for (const [key, value] of Object.entries(fileData.current)) {
90
+ data[key] = value;
91
+ }
92
+
93
+ return { workdir: webcontainer.workdir, data };
94
+ },
95
+ [webcontainer],
96
+ );
97
+
98
+ return { ready, gitClone };
99
+ }
100
+
101
+ const getFs = (
102
+ webcontainer: WebContainer,
103
+ record: MutableRefObject<Record<string, { data: any; encoding?: string }>>,
104
+ ) => ({
105
+ promises: {
106
+ readFile: async (path: string, options: any) => {
107
+ const encoding = options.encoding;
108
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
109
+ console.log('readFile', relativePath, encoding);
110
+
111
+ return await webcontainer.fs.readFile(relativePath, encoding);
112
+ },
113
+ writeFile: async (path: string, data: any, options: any) => {
114
+ const encoding = options.encoding;
115
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
116
+ console.log('writeFile', { relativePath, data, encoding });
117
+
118
+ if (record.current) {
119
+ record.current[relativePath] = { data, encoding };
120
+ }
121
+
122
+ return await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding });
123
+ },
124
+ mkdir: async (path: string, options: any) => {
125
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
126
+ console.log('mkdir', relativePath, options);
127
+
128
+ return await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true });
129
+ },
130
+ readdir: async (path: string, options: any) => {
131
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
132
+ console.log('readdir', relativePath, options);
133
+
134
+ return await webcontainer.fs.readdir(relativePath, options);
135
+ },
136
+ rm: async (path: string, options: any) => {
137
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
138
+ console.log('rm', relativePath, options);
139
+
140
+ return await webcontainer.fs.rm(relativePath, { ...(options || {}) });
141
+ },
142
+ rmdir: async (path: string, options: any) => {
143
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
144
+ console.log('rmdir', relativePath, options);
145
+
146
+ return await webcontainer.fs.rm(relativePath, { recursive: true, ...options });
147
+ },
148
+
149
+ // Mock implementations for missing functions
150
+ unlink: async (path: string) => {
151
+ // unlink is just removing a single file
152
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
153
+ return await webcontainer.fs.rm(relativePath, { recursive: false });
154
+ },
155
+
156
+ stat: async (path: string) => {
157
+ try {
158
+ const relativePath = pathUtils.relative(webcontainer.workdir, path);
159
+ const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true });
160
+ const name = pathUtils.basename(relativePath);
161
+ const fileInfo = resp.find((x) => x.name == name);
162
+
163
+ if (!fileInfo) {
164
+ throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
165
+ }
166
+
167
+ return {
168
+ isFile: () => fileInfo.isFile(),
169
+ isDirectory: () => fileInfo.isDirectory(),
170
+ isSymbolicLink: () => false,
171
+ size: 1,
172
+ mode: 0o666, // Default permissions
173
+ mtimeMs: Date.now(),
174
+ uid: 1000,
175
+ gid: 1000,
176
+ };
177
+ } catch (error: any) {
178
+ console.log(error?.message);
179
+
180
+ const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
181
+ err.code = 'ENOENT';
182
+ err.errno = -2;
183
+ err.syscall = 'stat';
184
+ err.path = path;
185
+ throw err;
186
+ }
187
+ },
188
+
189
+ lstat: async (path: string) => {
190
+ /*
191
+ * For basic usage, lstat can return the same as stat
192
+ * since we're not handling symbolic links
193
+ */
194
+ return await getFs(webcontainer, record).promises.stat(path);
195
+ },
196
+
197
+ readlink: async (path: string) => {
198
+ /*
199
+ * Since WebContainer doesn't support symlinks,
200
+ * we'll throw a "not a symbolic link" error
201
+ */
202
+ throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
203
+ },
204
+
205
+ symlink: async (target: string, path: string) => {
206
+ /*
207
+ * Since WebContainer doesn't support symlinks,
208
+ * we'll throw a "operation not supported" error
209
+ */
210
+ throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`);
211
+ },
212
+
213
+ chmod: async (_path: string, _mode: number) => {
214
+ /*
215
+ * WebContainer doesn't support changing permissions,
216
+ * but we can pretend it succeeded for compatibility
217
+ */
218
+ return await Promise.resolve();
219
+ },
220
+ },
221
+ });
222
+
223
+ const pathUtils = {
224
+ dirname: (path: string) => {
225
+ // Handle empty or just filename cases
226
+ if (!path || !path.includes('/')) {
227
+ return '.';
228
+ }
229
+
230
+ // Remove trailing slashes
231
+ path = path.replace(/\/+$/, '');
232
+
233
+ // Get directory part
234
+ return path.split('/').slice(0, -1).join('/') || '/';
235
+ },
236
+
237
+ basename: (path: string, ext?: string) => {
238
+ // Remove trailing slashes
239
+ path = path.replace(/\/+$/, '');
240
+
241
+ // Get the last part of the path
242
+ const base = path.split('/').pop() || '';
243
+
244
+ // If extension is provided, remove it from the result
245
+ if (ext && base.endsWith(ext)) {
246
+ return base.slice(0, -ext.length);
247
+ }
248
+
249
+ return base;
250
+ },
251
+ relative: (from: string, to: string): string => {
252
+ // Handle empty inputs
253
+ if (!from || !to) {
254
+ return '.';
255
+ }
256
+
257
+ // Normalize paths by removing trailing slashes and splitting
258
+ const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean);
259
+
260
+ const fromParts = normalizePathParts(from);
261
+ const toParts = normalizePathParts(to);
262
+
263
+ // Find common parts at the start of both paths
264
+ let commonLength = 0;
265
+ const minLength = Math.min(fromParts.length, toParts.length);
266
+
267
+ for (let i = 0; i < minLength; i++) {
268
+ if (fromParts[i] !== toParts[i]) {
269
+ break;
270
+ }
271
+
272
+ commonLength++;
273
+ }
274
+
275
+ // Calculate the number of "../" needed
276
+ const upCount = fromParts.length - commonLength;
277
+
278
+ // Get the remaining path parts we need to append
279
+ const remainingPath = toParts.slice(commonLength);
280
+
281
+ // Construct the relative path
282
+ const relativeParts = [...Array(upCount).fill('..'), ...remainingPath];
283
+
284
+ // Handle empty result case
285
+ return relativeParts.length === 0 ? '.' : relativeParts.join('/');
286
+ },
287
+ };
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/__snapshots__/message-parser.spec.ts.snap CHANGED
@@ -29,6 +29,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
29
  "id": "artifact_1",
30
  "messageId": "message_1",
31
  "title": "Some title",
 
32
  }
33
  `;
34
 
@@ -37,6 +38,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
37
  "id": "artifact_1",
38
  "messageId": "message_1",
39
  "title": "Some title",
 
40
  }
41
  `;
42
 
@@ -96,6 +98,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
96
  "id": "artifact_1",
97
  "messageId": "message_1",
98
  "title": "Some title",
 
99
  }
100
  `;
101
 
@@ -104,6 +107,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
104
  "id": "artifact_1",
105
  "messageId": "message_1",
106
  "title": "Some title",
 
107
  }
108
  `;
109
 
@@ -112,6 +116,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
112
  "id": "artifact_1",
113
  "messageId": "message_1",
114
  "title": "Some title",
 
115
  }
116
  `;
117
 
@@ -120,6 +125,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
120
  "id": "artifact_1",
121
  "messageId": "message_1",
122
  "title": "Some title",
 
123
  }
124
  `;
125
 
@@ -128,6 +134,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
128
  "id": "artifact_1",
129
  "messageId": "message_1",
130
  "title": "Some title",
 
131
  }
132
  `;
133
 
@@ -136,6 +143,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
136
  "id": "artifact_1",
137
  "messageId": "message_1",
138
  "title": "Some title",
 
139
  }
140
  `;
141
 
@@ -144,6 +152,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
144
  "id": "artifact_1",
145
  "messageId": "message_1",
146
  "title": "Some title",
 
147
  }
148
  `;
149
 
@@ -152,6 +161,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
152
  "id": "artifact_1",
153
  "messageId": "message_1",
154
  "title": "Some title",
 
155
  }
156
  `;
157
 
@@ -160,6 +170,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
160
  "id": "artifact_1",
161
  "messageId": "message_1",
162
  "title": "Some title",
 
163
  }
164
  `;
165
 
@@ -168,6 +179,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
168
  "id": "artifact_1",
169
  "messageId": "message_1",
170
  "title": "Some title",
 
171
  }
172
  `;
173
 
@@ -176,6 +188,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
176
  "id": "artifact_1",
177
  "messageId": "message_1",
178
  "title": "Some title",
 
179
  }
180
  `;
181
 
@@ -184,6 +197,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
184
  "id": "artifact_1",
185
  "messageId": "message_1",
186
  "title": "Some title",
 
187
  }
188
  `;
189
 
@@ -192,6 +206,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
192
  "id": "artifact_1",
193
  "messageId": "message_1",
194
  "title": "Some title",
 
195
  }
196
  `;
197
 
@@ -200,6 +215,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
200
  "id": "artifact_1",
201
  "messageId": "message_1",
202
  "title": "Some title",
 
203
  }
204
  `;
205
 
@@ -208,6 +224,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
208
  "id": "artifact_1",
209
  "messageId": "message_1",
210
  "title": "Some title",
 
211
  }
212
  `;
213
 
@@ -216,5 +233,6 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
216
  "id": "artifact_1",
217
  "messageId": "message_1",
218
  "title": "Some title",
 
219
  }
220
  `;
 
29
  "id": "artifact_1",
30
  "messageId": "message_1",
31
  "title": "Some title",
32
+ "type": undefined,
33
  }
34
  `;
35
 
 
38
  "id": "artifact_1",
39
  "messageId": "message_1",
40
  "title": "Some title",
41
+ "type": undefined,
42
  }
43
  `;
44
 
 
98
  "id": "artifact_1",
99
  "messageId": "message_1",
100
  "title": "Some title",
101
+ "type": undefined,
102
  }
103
  `;
104
 
 
107
  "id": "artifact_1",
108
  "messageId": "message_1",
109
  "title": "Some title",
110
+ "type": undefined,
111
  }
112
  `;
113
 
 
116
  "id": "artifact_1",
117
  "messageId": "message_1",
118
  "title": "Some title",
119
+ "type": undefined,
120
  }
121
  `;
122
 
 
125
  "id": "artifact_1",
126
  "messageId": "message_1",
127
  "title": "Some title",
128
+ "type": undefined,
129
  }
130
  `;
131
 
 
134
  "id": "artifact_1",
135
  "messageId": "message_1",
136
  "title": "Some title",
137
+ "type": "bundled",
138
  }
139
  `;
140
 
 
143
  "id": "artifact_1",
144
  "messageId": "message_1",
145
  "title": "Some title",
146
+ "type": "bundled",
147
  }
148
  `;
149
 
 
152
  "id": "artifact_1",
153
  "messageId": "message_1",
154
  "title": "Some title",
155
+ "type": undefined,
156
  }
157
  `;
158
 
 
161
  "id": "artifact_1",
162
  "messageId": "message_1",
163
  "title": "Some title",
164
+ "type": undefined,
165
  }
166
  `;
167
 
 
170
  "id": "artifact_1",
171
  "messageId": "message_1",
172
  "title": "Some title",
173
+ "type": undefined,
174
  }
175
  `;
176
 
 
179
  "id": "artifact_1",
180
  "messageId": "message_1",
181
  "title": "Some title",
182
+ "type": undefined,
183
  }
184
  `;
185
 
 
188
  "id": "artifact_1",
189
  "messageId": "message_1",
190
  "title": "Some title",
191
+ "type": undefined,
192
  }
193
  `;
194
 
 
197
  "id": "artifact_1",
198
  "messageId": "message_1",
199
  "title": "Some title",
200
+ "type": undefined,
201
  }
202
  `;
203
 
 
206
  "id": "artifact_1",
207
  "messageId": "message_1",
208
  "title": "Some title",
209
+ "type": undefined,
210
  }
211
  `;
212
 
 
215
  "id": "artifact_1",
216
  "messageId": "message_1",
217
  "title": "Some title",
218
+ "type": undefined,
219
  }
220
  `;
221
 
 
224
  "id": "artifact_1",
225
  "messageId": "message_1",
226
  "title": "Some title",
227
+ "type": undefined,
228
  }
229
  `;
230
 
 
233
  "id": "artifact_1",
234
  "messageId": "message_1",
235
  "title": "Some title",
236
+ "type": undefined,
237
  }
238
  `;
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/runtime/message-parser.spec.ts CHANGED
@@ -59,7 +59,11 @@ describe('StreamingMessageParser', () => {
59
  },
60
  ],
61
  [
62
- ['Some text before <boltArti', 'fact', ' title="Some title" id="artifact_1">foo</boltArtifact> Some more text'],
 
 
 
 
63
  {
64
  output: 'Some text before Some more text',
65
  callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
 
59
  },
60
  ],
61
  [
62
+ [
63
+ 'Some text before <boltArti',
64
+ 'fact',
65
+ ' title="Some title" id="artifact_1" type="bundled" >foo</boltArtifact> Some more text',
66
+ ],
67
  {
68
  output: 'Some text before Some more text',
69
  callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },
app/lib/runtime/message-parser.ts CHANGED
@@ -192,6 +192,7 @@ export class StreamingMessageParser {
192
  const artifactTag = input.slice(i, openTagEnd + 1);
193
 
194
  const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
 
195
  const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
196
 
197
  if (!artifactTitle) {
@@ -207,6 +208,7 @@ export class StreamingMessageParser {
207
  const currentArtifact = {
208
  id: artifactId,
209
  title: artifactTitle,
 
210
  } satisfies BoltArtifactData;
211
 
212
  state.currentArtifact = currentArtifact;
 
192
  const artifactTag = input.slice(i, openTagEnd + 1);
193
 
194
  const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
195
+ const type = this.#extractAttribute(artifactTag, 'type') as string;
196
  const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
197
 
198
  if (!artifactTitle) {
 
208
  const currentArtifact = {
209
  id: artifactId,
210
  title: artifactTitle,
211
+ type,
212
  } satisfies BoltArtifactData;
213
 
214
  state.currentArtifact = currentArtifact;
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/lib/stores/workbench.ts CHANGED
@@ -19,6 +19,7 @@ import { description } from '~/lib/persistence';
19
  export interface ArtifactState {
20
  id: string;
21
  title: string;
 
22
  closed: boolean;
23
  runner: ActionRunner;
24
  }
@@ -230,7 +231,7 @@ export class WorkbenchStore {
230
  // TODO: what do we wanna do and how do we wanna recover from this?
231
  }
232
 
233
- addArtifact({ messageId, title, id }: ArtifactCallbackData) {
234
  const artifact = this.#getArtifact(messageId);
235
 
236
  if (artifact) {
@@ -245,6 +246,7 @@ export class WorkbenchStore {
245
  id,
246
  title,
247
  closed: false,
 
248
  runner: new ActionRunner(webcontainer, () => this.boltTerminal),
249
  });
250
  }
 
19
  export interface ArtifactState {
20
  id: string;
21
  title: string;
22
+ type?: string;
23
  closed: boolean;
24
  runner: ActionRunner;
25
  }
 
231
  // TODO: what do we wanna do and how do we wanna recover from this?
232
  }
233
 
234
+ addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
235
  const artifact = this.#getArtifact(messageId);
236
 
237
  if (artifact) {
 
246
  id,
247
  title,
248
  closed: false,
249
+ type,
250
  runner: new ActionRunner(webcontainer, () => this.boltTerminal),
251
  });
252
  }
app/routes/api.chat.ts CHANGED
@@ -1,6 +1,3 @@
1
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
2
- // @ts-nocheck – TODO: Provider proper types
3
-
4
  import { type ActionFunctionArgs } from '@remix-run/cloudflare';
5
  import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
6
  import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
@@ -11,8 +8,8 @@ export async function action(args: ActionFunctionArgs) {
11
  return chatAction(args);
12
  }
13
 
14
- function parseCookies(cookieHeader) {
15
- const cookies = {};
16
 
17
  // Split the cookie string by semicolons and spaces
18
  const items = cookieHeader.split(';').map((cookie) => cookie.trim());
@@ -34,19 +31,19 @@ function parseCookies(cookieHeader) {
34
  async function chatAction({ context, request }: ActionFunctionArgs) {
35
  const { messages } = await request.json<{
36
  messages: Messages;
 
37
  }>();
38
 
39
  const cookieHeader = request.headers.get('Cookie');
40
 
41
  // Parse the cookie's value (returns an object or null if no cookie exists)
42
- const apiKeys = JSON.parse(parseCookies(cookieHeader).apiKeys || '{}');
43
 
44
  const stream = new SwitchableStream();
45
 
46
  try {
47
  const options: StreamingOptions = {
48
  toolChoice: 'none',
49
- apiKeys,
50
  onFinish: async ({ text: content, finishReason }) => {
51
  if (finishReason !== 'length') {
52
  return stream.close();
@@ -63,7 +60,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
63
  messages.push({ role: 'assistant', content });
64
  messages.push({ role: 'user', content: CONTINUE_PROMPT });
65
 
66
- const result = await streamText(messages, context.cloudflare.env, options);
67
 
68
  return stream.switchSource(result.toAIStream());
69
  },
@@ -79,7 +76,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
79
  contentType: 'text/plain; charset=utf-8',
80
  },
81
  });
82
- } catch (error) {
83
  console.log(error);
84
 
85
  if (error.message?.includes('API key')) {
 
 
 
 
1
  import { type ActionFunctionArgs } from '@remix-run/cloudflare';
2
  import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
3
  import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
 
8
  return chatAction(args);
9
  }
10
 
11
+ function parseCookies(cookieHeader: string) {
12
+ const cookies: any = {};
13
 
14
  // Split the cookie string by semicolons and spaces
15
  const items = cookieHeader.split(';').map((cookie) => cookie.trim());
 
31
  async function chatAction({ context, request }: ActionFunctionArgs) {
32
  const { messages } = await request.json<{
33
  messages: Messages;
34
+ model: string;
35
  }>();
36
 
37
  const cookieHeader = request.headers.get('Cookie');
38
 
39
  // Parse the cookie's value (returns an object or null if no cookie exists)
40
+ const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
41
 
42
  const stream = new SwitchableStream();
43
 
44
  try {
45
  const options: StreamingOptions = {
46
  toolChoice: 'none',
 
47
  onFinish: async ({ text: content, finishReason }) => {
48
  if (finishReason !== 'length') {
49
  return stream.close();
 
60
  messages.push({ role: 'assistant', content });
61
  messages.push({ role: 'user', content: CONTINUE_PROMPT });
62
 
63
+ const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
64
 
65
  return stream.switchSource(result.toAIStream());
66
  },
 
76
  contentType: 'text/plain; charset=utf-8',
77
  },
78
  });
79
+ } catch (error: any) {
80
  console.log(error);
81
 
82
  if (error.message?.includes('API key')) {
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/artifact.ts CHANGED
@@ -1,4 +1,5 @@
1
  export interface BoltArtifactData {
2
  id: string;
3
  title: string;
 
4
  }
 
1
  export interface BoltArtifactData {
2
  id: string;
3
  title: string;
4
+ type?: string | undefined;
5
  }
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
  }
app/types/model.ts CHANGED
@@ -3,7 +3,7 @@ import type { ModelInfo } from '~/utils/types';
3
  export type ProviderInfo = {
4
  staticModels: ModelInfo[];
5
  name: string;
6
- getDynamicModels?: () => Promise<ModelInfo[]>;
7
  getApiKeyLink?: string;
8
  labelForGetApiKey?: string;
9
  icon?: string;
 
3
  export type ProviderInfo = {
4
  staticModels: ModelInfo[];
5
  name: string;
6
+ getDynamicModels?: (apiKeys?: Record<string, string>) => Promise<ModelInfo[]>;
7
  getApiKeyLink?: string;
8
  labelForGetApiKey?: string;
9
  icon?: string;
app/utils/constants.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types';
2
  import type { ProviderInfo } from '~/types/model';
3
 
@@ -262,6 +263,7 @@ const PROVIDER_LIST: ProviderInfo[] = [
262
  },
263
  {
264
  name: 'Together',
 
265
  staticModels: [
266
  {
267
  name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
@@ -293,6 +295,61 @@ const staticModels: ModelInfo[] = PROVIDER_LIST.map((p) => p.staticModels).flat(
293
 
294
  export let MODEL_LIST: ModelInfo[] = [...staticModels];
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  const getOllamaBaseUrl = () => {
297
  const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
298
 
@@ -340,7 +397,14 @@ async function getOpenAILikeModels(): Promise<ModelInfo[]> {
340
  return [];
341
  }
342
 
343
- const apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
 
 
 
 
 
 
 
344
  const response = await fetch(`${baseUrl}/models`, {
345
  headers: {
346
  Authorization: `Bearer ${apiKey}`,
@@ -414,16 +478,32 @@ async function getLMStudioModels(): Promise<ModelInfo[]> {
414
  }
415
 
416
  async function initializeModelList(): Promise<ModelInfo[]> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  MODEL_LIST = [
418
  ...(
419
  await Promise.all(
420
  PROVIDER_LIST.filter(
421
  (p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
422
- ).map((p) => p.getDynamicModels()),
423
  )
424
  ).flat(),
425
  ...staticModels,
426
  ];
 
427
  return MODEL_LIST;
428
  }
429
 
 
1
+ import Cookies from 'js-cookie';
2
  import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types';
3
  import type { ProviderInfo } from '~/types/model';
4
 
 
263
  },
264
  {
265
  name: 'Together',
266
+ getDynamicModels: getTogetherModels,
267
  staticModels: [
268
  {
269
  name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
 
295
 
296
  export let MODEL_LIST: ModelInfo[] = [...staticModels];
297
 
298
+ export async function getModelList(apiKeys: Record<string, string>) {
299
+ MODEL_LIST = [
300
+ ...(
301
+ await Promise.all(
302
+ PROVIDER_LIST.filter(
303
+ (p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
304
+ ).map((p) => p.getDynamicModels(apiKeys)),
305
+ )
306
+ ).flat(),
307
+ ...staticModels,
308
+ ];
309
+ return MODEL_LIST;
310
+ }
311
+
312
+ async function getTogetherModels(apiKeys?: Record<string, string>): Promise<ModelInfo[]> {
313
+ try {
314
+ const baseUrl = import.meta.env.TOGETHER_API_BASE_URL || '';
315
+ const provider = 'Together';
316
+
317
+ if (!baseUrl) {
318
+ return [];
319
+ }
320
+
321
+ let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
322
+
323
+ if (apiKeys && apiKeys[provider]) {
324
+ apiKey = apiKeys[provider];
325
+ }
326
+
327
+ if (!apiKey) {
328
+ return [];
329
+ }
330
+
331
+ const response = await fetch(`${baseUrl}/models`, {
332
+ headers: {
333
+ Authorization: `Bearer ${apiKey}`,
334
+ },
335
+ });
336
+ const res = (await response.json()) as any;
337
+ const data: any[] = (res || []).filter((model: any) => model.type == 'chat');
338
+
339
+ return data.map((m: any) => ({
340
+ name: m.id,
341
+ label: `${m.display_name} - in:$${m.pricing.input.toFixed(
342
+ 2,
343
+ )} out:$${m.pricing.output.toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
344
+ provider,
345
+ maxTokenAllowed: 8000,
346
+ }));
347
+ } catch (e) {
348
+ console.error('Error getting OpenAILike models:', e);
349
+ return [];
350
+ }
351
+ }
352
+
353
  const getOllamaBaseUrl = () => {
354
  const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
355
 
 
397
  return [];
398
  }
399
 
400
+ let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
401
+
402
+ const apikeys = JSON.parse(Cookies.get('apiKeys') || '{}');
403
+
404
+ if (apikeys && apikeys.OpenAILike) {
405
+ apiKey = apikeys.OpenAILike;
406
+ }
407
+
408
  const response = await fetch(`${baseUrl}/models`, {
409
  headers: {
410
  Authorization: `Bearer ${apiKey}`,
 
478
  }
479
 
480
  async function initializeModelList(): Promise<ModelInfo[]> {
481
+ let apiKeys: Record<string, string> = {};
482
+
483
+ try {
484
+ const storedApiKeys = Cookies.get('apiKeys');
485
+
486
+ if (storedApiKeys) {
487
+ const parsedKeys = JSON.parse(storedApiKeys);
488
+
489
+ if (typeof parsedKeys === 'object' && parsedKeys !== null) {
490
+ apiKeys = parsedKeys;
491
+ }
492
+ }
493
+ } catch (error: any) {
494
+ console.warn(`Failed to fetch apikeys from cookies:${error?.message}`);
495
+ }
496
  MODEL_LIST = [
497
  ...(
498
  await Promise.all(
499
  PROVIDER_LIST.filter(
500
  (p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
501
+ ).map((p) => p.getDynamicModels(apiKeys)),
502
  )
503
  ).flat(),
504
  ...staticModels,
505
  ];
506
+
507
  return MODEL_LIST;
508
  }
509
 
package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -58,6 +58,7 @@
58
  "@openrouter/ai-sdk-provider": "^0.0.5",
59
  "@radix-ui/react-dialog": "^1.1.2",
60
  "@radix-ui/react-dropdown-menu": "^2.1.2",
 
61
  "@radix-ui/react-tooltip": "^1.1.4",
62
  "@remix-run/cloudflare": "^2.15.0",
63
  "@remix-run/cloudflare-pages": "^2.15.0",
@@ -75,13 +76,13 @@
75
  "framer-motion": "^11.12.0",
76
  "ignore": "^6.0.2",
77
  "isbot": "^4.4.0",
 
78
  "istextorbinary": "^9.5.0",
79
  "jose": "^5.9.6",
80
  "js-cookie": "^3.0.5",
81
  "jszip": "^3.10.1",
82
  "nanostores": "^0.10.3",
83
  "ollama-ai-provider": "^0.15.2",
84
- "pnpm": "^9.14.4",
85
  "react": "^18.3.1",
86
  "react-dom": "^18.3.1",
87
  "react-hotkeys-hook": "^4.6.1",
@@ -101,6 +102,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",
@@ -109,6 +111,7 @@
109
  "husky": "9.1.7",
110
  "is-ci": "^3.0.1",
111
  "node-fetch": "^3.3.2",
 
112
  "prettier": "^3.4.1",
113
  "sass-embedded": "^1.81.0",
114
  "typescript": "^5.7.2",
 
58
  "@openrouter/ai-sdk-provider": "^0.0.5",
59
  "@radix-ui/react-dialog": "^1.1.2",
60
  "@radix-ui/react-dropdown-menu": "^2.1.2",
61
+ "@radix-ui/react-separator": "^1.1.0",
62
  "@radix-ui/react-tooltip": "^1.1.4",
63
  "@remix-run/cloudflare": "^2.15.0",
64
  "@remix-run/cloudflare-pages": "^2.15.0",
 
76
  "framer-motion": "^11.12.0",
77
  "ignore": "^6.0.2",
78
  "isbot": "^4.4.0",
79
+ "isomorphic-git": "^1.27.2",
80
  "istextorbinary": "^9.5.0",
81
  "jose": "^5.9.6",
82
  "js-cookie": "^3.0.5",
83
  "jszip": "^3.10.1",
84
  "nanostores": "^0.10.3",
85
  "ollama-ai-provider": "^0.15.2",
 
86
  "react": "^18.3.1",
87
  "react-dom": "^18.3.1",
88
  "react-hotkeys-hook": "^4.6.1",
 
102
  "@cloudflare/workers-types": "^4.20241127.0",
103
  "@remix-run/dev": "^2.15.0",
104
  "@types/diff": "^5.2.3",
105
+ "@types/dom-speech-recognition": "^0.0.4",
106
  "@types/file-saver": "^2.0.7",
107
  "@types/js-cookie": "^3.0.6",
108
  "@types/react": "^18.3.12",
 
111
  "husky": "9.1.7",
112
  "is-ci": "^3.0.1",
113
  "node-fetch": "^3.3.2",
114
+ "pnpm": "^9.14.4",
115
  "prettier": "^3.4.1",
116
  "sass-embedded": "^1.81.0",
117
  "typescript": "^5.7.2",
pnpm-lock.yaml CHANGED
The diff for this file is too large to render. See raw diff
 
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(),
@@ -28,7 +27,7 @@ export default defineConfig((config) => {
28
  chrome129IssuePlugin(),
29
  config.mode === 'production' && optimizeCssModules({ apply: 'build' }),
30
  ],
31
- envPrefix:["VITE_","OPENAI_LIKE_API_","OLLAMA_API_BASE_URL","LMSTUDIO_API_BASE_URL"],
32
  css: {
33
  preprocessorOptions: {
34
  scss: {
 
19
  future: {
20
  v3_fetcherPersist: true,
21
  v3_relativeSplatPath: true,
22
+ v3_throwAbortReason: true
 
23
  },
24
  }),
25
  UnoCSS(),
 
27
  chrome129IssuePlugin(),
28
  config.mode === 'production' && optimizeCssModules({ apply: 'build' }),
29
  ],
30
+ envPrefix: ["VITE_", "OPENAI_LIKE_API_", "OLLAMA_API_BASE_URL", "LMSTUDIO_API_BASE_URL","TOGETHER_API_BASE_URL"],
31
  css: {
32
  preprocessorOptions: {
33
  scss: {