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