mirror of
https://github.com/stackblitz/bolt.new
synced 2025-03-12 06:51:11 +00:00
Merge branch 'main' into together-ai-dynamic-model-list
This commit is contained in:
commit
5ead47992d
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -56,6 +56,16 @@ body:
|
|||||||
- OS: [e.g. macOS, Windows, Linux]
|
- OS: [e.g. macOS, Windows, Linux]
|
||||||
- Browser: [e.g. Chrome, Safari, Firefox]
|
- Browser: [e.g. Chrome, Safari, Firefox]
|
||||||
- Version: [e.g. 91.1]
|
- Version: [e.g. 91.1]
|
||||||
|
- type: input
|
||||||
|
id: provider
|
||||||
|
attributes:
|
||||||
|
label: Provider Used
|
||||||
|
description: Tell us the provider you are using.
|
||||||
|
- type: input
|
||||||
|
id: model
|
||||||
|
attributes:
|
||||||
|
label: Model Used
|
||||||
|
description: Tell us the model you are using.
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional
|
id: additional
|
||||||
attributes:
|
attributes:
|
||||||
|
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@ -16,10 +16,10 @@ jobs:
|
|||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
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."
|
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."
|
||||||
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."
|
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."
|
||||||
days-before-stale: 14 # Number of days before marking an issue or PR as stale
|
days-before-stale: 10 # Number of days before marking an issue or PR as stale
|
||||||
days-before-close: 7 # Number of days after being marked stale before closing
|
days-before-close: 4 # Number of days after being marked stale before closing
|
||||||
stale-issue-label: "stale" # Label to apply to stale issues
|
stale-issue-label: "stale" # Label to apply to stale issues
|
||||||
stale-pr-label: "stale" # Label to apply to stale pull requests
|
stale-pr-label: "stale" # Label to apply to stale pull requests
|
||||||
exempt-issue-labels: "pinned,important" # Issues with these labels won't be marked stale
|
exempt-issue-labels: "pinned,important" # Issues with these labels won't be marked stale
|
||||||
exempt-pr-labels: "pinned,important" # PRs with these labels won't be marked stale
|
exempt-pr-labels: "pinned,important" # PRs with these labels won't be marked stale
|
||||||
operations-per-run: 90 # Limits the number of actions per run to avoid API rate limits
|
operations-per-run: 75 # Limits the number of actions per run to avoid API rate limits
|
||||||
|
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
|
echo "🔍 Running pre-commit hook to check the code looks good... 🔍"
|
||||||
|
|
||||||
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # Load nvm if you're using i
|
||||||
|
|
||||||
if ! pnpm typecheck; then
|
if ! pnpm typecheck; then
|
||||||
echo "❌ Type checking failed! Please review TypeScript types."
|
echo "❌ Type checking failed! Please review TypeScript types."
|
||||||
echo "Once you're done, don't forget to add your changes to the commit! 🚀"
|
echo "Once you're done, don't forget to add your changes to the commit! 🚀"
|
||||||
@ -9,7 +12,7 @@ if ! pnpm typecheck; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if ! pnpm lint; then
|
if ! pnpm lint; then
|
||||||
echo "❌ Linting failed! 'pnpm lint:check' will help you fix the easy ones."
|
echo "❌ Linting failed! 'pnpm lint:fix' will help you fix the easy ones."
|
||||||
echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
|
echo "Once you're done, don't forget to add your beautification to the commit! 🤩"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
12
README.md
12
README.md
@ -4,10 +4,13 @@
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
Check the [oTToDev Docs](https://coleam00.github.io/bolt.new-any-llm/) for more information.
|
||||||
|
|
||||||
## Join the community for oTToDev!
|
## Join the community for oTToDev!
|
||||||
|
|
||||||
https://thinktank.ottomator.ai
|
https://thinktank.ottomator.ai
|
||||||
|
|
||||||
|
|
||||||
## Requested Additions - Feel Free to Contribute!
|
## Requested Additions - Feel Free to Contribute!
|
||||||
|
|
||||||
- ✅ OpenRouter Integration (@coleam00)
|
- ✅ OpenRouter Integration (@coleam00)
|
||||||
@ -31,23 +34,24 @@ https://thinktank.ottomator.ai
|
|||||||
- ✅ Ability to revert code to earlier version (@wonderwhy-er)
|
- ✅ Ability to revert code to earlier version (@wonderwhy-er)
|
||||||
- ✅ Cohere Integration (@hasanraiyan)
|
- ✅ Cohere Integration (@hasanraiyan)
|
||||||
- ✅ Dynamic model max token length (@hasanraiyan)
|
- ✅ Dynamic model max token length (@hasanraiyan)
|
||||||
|
- ✅ Better prompt enhancing (@SujalXplores)
|
||||||
- ✅ Prompt caching (@SujalXplores)
|
- ✅ Prompt caching (@SujalXplores)
|
||||||
- ✅ Load local projects into the app (@wonderwhy-er)
|
- ✅ Load local projects into the app (@wonderwhy-er)
|
||||||
- ✅ Together Integration (@mouimet-infinisoft)
|
- ✅ Together Integration (@mouimet-infinisoft)
|
||||||
- ✅ Mobile friendly (@qwikode)
|
- ✅ Mobile friendly (@qwikode)
|
||||||
- ✅ Better prompt enhancing (@SujalXplores)
|
- ✅ Better prompt enhancing (@SujalXplores)
|
||||||
- ⬜ **HIGH PRIORITY** - ALMOST DONE - Attach images to prompts (@atrokhym)
|
- ✅ Attach images to prompts (@atrokhym)
|
||||||
- ⬜ **HIGH PRIORITY** - Prevent Bolt from rewriting files as often (file locking and diffs)
|
- ⬜ **HIGH PRIORITY** - Prevent Bolt from rewriting files as often (file locking and diffs)
|
||||||
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
||||||
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
||||||
- ⬜ Azure Open AI API Integration
|
|
||||||
- ⬜ Perplexity Integration
|
|
||||||
- ⬜ Vertex AI Integration
|
|
||||||
- ⬜ Deploy directly to Vercel/Netlify/other similar platforms
|
- ⬜ Deploy directly to Vercel/Netlify/other similar platforms
|
||||||
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
- ⬜ Have LLM plan the project in a MD file for better results/transparency
|
||||||
- ⬜ VSCode Integration with git-like confirmations
|
- ⬜ VSCode Integration with git-like confirmations
|
||||||
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
|
- ⬜ Upload documents for knowledge - UI design templates, a code base to reference coding style, etc.
|
||||||
- ⬜ Voice prompting
|
- ⬜ Voice prompting
|
||||||
|
- ⬜ Azure Open AI API Integration
|
||||||
|
- ⬜ Perplexity Integration
|
||||||
|
- ⬜ Vertex AI Integration
|
||||||
|
|
||||||
## Bolt.new: AI-Powered Full-Stack Web Development in the Browser
|
## Bolt.new: AI-Powered Full-Stack Web Development in the Browser
|
||||||
|
|
||||||
|
@ -22,44 +22,9 @@ import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportCh
|
|||||||
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
||||||
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||||
|
|
||||||
// @ts-ignore TODO: Introduce proper types
|
import FilePreview from './FilePreview';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
import { ModelSelector } from '~/components/chat/ModelSelector';
|
||||||
const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => {
|
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
||||||
return (
|
|
||||||
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
|
||||||
<select
|
|
||||||
value={provider?.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setProvider(providerList.find((p: ProviderInfo) => p.name === e.target.value));
|
|
||||||
|
|
||||||
const firstModel = [...modelList].find((m) => m.provider == e.target.value);
|
|
||||||
setModel(firstModel ? firstModel.name : '');
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{providerList.map((provider: ProviderInfo) => (
|
|
||||||
<option key={provider.name} value={provider.name}>
|
|
||||||
{provider.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
key={provider?.name}
|
|
||||||
value={model}
|
|
||||||
onChange={(e) => setModel(e.target.value)}
|
|
||||||
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%]"
|
|
||||||
>
|
|
||||||
{[...modelList]
|
|
||||||
.filter((e) => e.provider == provider?.name && e.name)
|
|
||||||
.map((modelOption) => (
|
|
||||||
<option key={modelOption.name} value={modelOption.name}>
|
|
||||||
{modelOption.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEXTAREA_MIN_HEIGHT = 76;
|
const TEXTAREA_MIN_HEIGHT = 76;
|
||||||
|
|
||||||
@ -85,6 +50,10 @@ interface BaseChatProps {
|
|||||||
enhancePrompt?: () => void;
|
enhancePrompt?: () => void;
|
||||||
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
||||||
exportChat?: () => void;
|
exportChat?: () => void;
|
||||||
|
uploadedFiles?: File[];
|
||||||
|
setUploadedFiles?: (files: File[]) => void;
|
||||||
|
imageDataList?: string[];
|
||||||
|
setImageDataList?: (dataList: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||||
@ -96,20 +65,24 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
showChat = true,
|
showChat = true,
|
||||||
chatStarted = false,
|
chatStarted = false,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
enhancingPrompt = false,
|
|
||||||
promptEnhanced = false,
|
|
||||||
messages,
|
|
||||||
input = '',
|
|
||||||
model,
|
model,
|
||||||
setModel,
|
setModel,
|
||||||
provider,
|
provider,
|
||||||
setProvider,
|
setProvider,
|
||||||
sendMessage,
|
input = '',
|
||||||
|
enhancingPrompt,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
|
promptEnhanced,
|
||||||
enhancePrompt,
|
enhancePrompt,
|
||||||
|
sendMessage,
|
||||||
handleStop,
|
handleStop,
|
||||||
importChat,
|
importChat,
|
||||||
exportChat,
|
exportChat,
|
||||||
|
uploadedFiles = [],
|
||||||
|
setUploadedFiles,
|
||||||
|
imageDataList = [],
|
||||||
|
setImageDataList,
|
||||||
|
messages,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@ -117,7 +90,11 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||||
const [modelList, setModelList] = useState(MODEL_LIST);
|
const [modelList, setModelList] = useState(MODEL_LIST);
|
||||||
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
|
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
||||||
|
const [transcript, setTranscript] = useState('');
|
||||||
|
|
||||||
|
console.log(transcript);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load API keys from cookies on component mount
|
// Load API keys from cookies on component mount
|
||||||
try {
|
try {
|
||||||
@ -140,8 +117,72 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
initializeModelList().then((modelList) => {
|
initializeModelList().then((modelList) => {
|
||||||
setModelList(modelList);
|
setModelList(modelList);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
const transcript = Array.from(event.results)
|
||||||
|
.map((result) => result[0])
|
||||||
|
.map((result) => result.transcript)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
setTranscript(transcript);
|
||||||
|
|
||||||
|
if (handleInputChange) {
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: { value: transcript },
|
||||||
|
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||||
|
handleInputChange(syntheticEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (event) => {
|
||||||
|
console.error('Speech recognition error:', event.error);
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
setRecognition(recognition);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const startListening = () => {
|
||||||
|
if (recognition) {
|
||||||
|
recognition.start();
|
||||||
|
setIsListening(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopListening = () => {
|
||||||
|
if (recognition) {
|
||||||
|
recognition.stop();
|
||||||
|
setIsListening(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
||||||
|
if (sendMessage) {
|
||||||
|
sendMessage(event, messageInput);
|
||||||
|
|
||||||
|
if (recognition) {
|
||||||
|
recognition.abort(); // Stop current recognition
|
||||||
|
setTranscript(''); // Clear transcript
|
||||||
|
setIsListening(false);
|
||||||
|
|
||||||
|
// Clear the input by triggering handleInputChange with empty value
|
||||||
|
if (handleInputChange) {
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: { value: '' },
|
||||||
|
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||||
|
handleInputChange(syntheticEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateApiKey = (provider: string, key: string) => {
|
const updateApiKey = (provider: string, key: string) => {
|
||||||
try {
|
try {
|
||||||
const updatedApiKeys = { ...apiKeys, [provider]: key };
|
const updatedApiKeys = { ...apiKeys, [provider]: key };
|
||||||
@ -159,6 +200,58 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const base64Image = e.target?.result as string;
|
||||||
|
setUploadedFiles?.([...uploadedFiles, file]);
|
||||||
|
setImageDataList?.([...imageDataList, base64Image]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
|
||||||
|
if (!items) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const file = item.getAsFile();
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const base64Image = e.target?.result as string;
|
||||||
|
setUploadedFiles?.([...uploadedFiles, file]);
|
||||||
|
setImageDataList?.([...imageDataList, base64Image]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const baseChat = (
|
const baseChat = (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@ -275,7 +368,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FilePreview
|
||||||
|
files={uploadedFiles}
|
||||||
|
imageDataList={imageDataList}
|
||||||
|
onRemove={(index) => {
|
||||||
|
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
|
||||||
|
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
||||||
@ -283,9 +383,41 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className={
|
className={classNames(
|
||||||
'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'
|
'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',
|
||||||
}
|
'transition-all duration-200',
|
||||||
|
'hover:border-bolt-elements-focus',
|
||||||
|
)}
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.style.border = '2px solid #1488fc';
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.style.border = '2px solid #1488fc';
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const base64Image = e.target?.result as string;
|
||||||
|
setUploadedFiles?.([...uploadedFiles, file]);
|
||||||
|
setImageDataList?.([...imageDataList, base64Image]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
@ -294,13 +426,19 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
sendMessage?.(event);
|
if (isStreaming) {
|
||||||
|
handleStop?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSendMessage?.(event);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
handleInputChange?.(event);
|
handleInputChange?.(event);
|
||||||
}}
|
}}
|
||||||
|
onPaste={handlePaste}
|
||||||
style={{
|
style={{
|
||||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||||
@ -311,7 +449,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
{() => (
|
{() => (
|
||||||
<SendButton
|
<SendButton
|
||||||
show={input.length > 0 || isStreaming}
|
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
@ -319,21 +457,28 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage?.(event);
|
if (input.length > 0 || uploadedFiles.length > 0) {
|
||||||
|
handleSendMessage?.(event);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
|
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
|
||||||
|
<div className="i-ph:paperclip text-xl"></div>
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
title="Enhance prompt"
|
title="Enhance prompt"
|
||||||
disabled={input.length === 0 || enhancingPrompt}
|
disabled={input.length === 0 || enhancingPrompt}
|
||||||
className={classNames('transition-all', {
|
className={classNames(
|
||||||
'opacity-100!': enhancingPrompt,
|
'transition-all',
|
||||||
'text-bolt-elements-item-contentAccent! pr-1.5 enabled:hover:bg-bolt-elements-item-backgroundAccent!':
|
enhancingPrompt ? 'opacity-100' : '',
|
||||||
promptEnhanced,
|
promptEnhanced ? 'text-bolt-elements-item-contentAccent' : '',
|
||||||
})}
|
promptEnhanced ? 'pr-1.5' : '',
|
||||||
|
promptEnhanced ? 'enabled:hover:bg-bolt-elements-item-backgroundAccent' : '',
|
||||||
|
)}
|
||||||
onClick={() => enhancePrompt?.()}
|
onClick={() => enhancePrompt?.()}
|
||||||
>
|
>
|
||||||
{enhancingPrompt ? (
|
{enhancingPrompt ? (
|
||||||
@ -348,6 +493,13 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
<SpeechRecognitionButton
|
||||||
|
isListening={isListening}
|
||||||
|
onStart={startListening}
|
||||||
|
onStop={stopListening}
|
||||||
|
disabled={isStreaming}
|
||||||
|
/>
|
||||||
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
|
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
|
||||||
</div>
|
</div>
|
||||||
{input.length > 3 ? (
|
{input.length > 3 ? (
|
||||||
@ -362,7 +514,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!chatStarted && ImportButtons(importChat)}
|
{!chatStarted && ImportButtons(importChat)}
|
||||||
{!chatStarted && ExamplePrompts(sendMessage)}
|
{!chatStarted &&
|
||||||
|
ExamplePrompts((event, messageInput) => {
|
||||||
|
if (isStreaming) {
|
||||||
|
handleStop?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSendMessage?.(event, messageInput);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,7 +12,6 @@ import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from
|
|||||||
import { description, useChatHistory } from '~/lib/persistence';
|
import { description, useChatHistory } from '~/lib/persistence';
|
||||||
import { chatStore } from '~/lib/stores/chat';
|
import { chatStore } from '~/lib/stores/chat';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
import { fileModificationsToHTML } from '~/utils/diff';
|
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
|
||||||
import { cubicEasingFn } from '~/utils/easings';
|
import { cubicEasingFn } from '~/utils/easings';
|
||||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||||
@ -89,8 +88,10 @@ export const ChatImpl = memo(
|
|||||||
useShortcuts();
|
useShortcuts();
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
||||||
|
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
||||||
|
|
||||||
const [model, setModel] = useState(() => {
|
const [model, setModel] = useState(() => {
|
||||||
const savedModel = Cookies.get('selectedModel');
|
const savedModel = Cookies.get('selectedModel');
|
||||||
return savedModel || DEFAULT_MODEL;
|
return savedModel || DEFAULT_MODEL;
|
||||||
@ -206,8 +207,6 @@ export const ChatImpl = memo(
|
|||||||
runAnimation();
|
runAnimation();
|
||||||
|
|
||||||
if (fileModifications !== undefined) {
|
if (fileModifications !== undefined) {
|
||||||
const diff = fileModificationsToHTML(fileModifications);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If we have file modifications we append a new user message manually since we have to prefix
|
* If we have file modifications we append a new user message manually since we have to prefix
|
||||||
* the user input with the file modifications and we don't want the new user input to appear
|
* 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(
|
|||||||
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
||||||
* aren't relevant here.
|
* aren't relevant here.
|
||||||
*/
|
*/
|
||||||
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${diff}\n\n${_input}` });
|
append({
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
||||||
|
},
|
||||||
|
...imageDataList.map((imageData) => ({
|
||||||
|
type: 'image',
|
||||||
|
image: imageData,
|
||||||
|
})),
|
||||||
|
] as any, // Type assertion to bypass compiler check
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After sending a new message we reset all modifications since the model
|
* After sending a new message we reset all modifications since the model
|
||||||
@ -223,12 +234,28 @@ export const ChatImpl = memo(
|
|||||||
*/
|
*/
|
||||||
workbenchStore.resetAllFileModifications();
|
workbenchStore.resetAllFileModifications();
|
||||||
} else {
|
} else {
|
||||||
append({ role: 'user', content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}` });
|
append({
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
||||||
|
},
|
||||||
|
...imageDataList.map((imageData) => ({
|
||||||
|
type: 'image',
|
||||||
|
image: imageData,
|
||||||
|
})),
|
||||||
|
] as any, // Type assertion to bypass compiler check
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
Cookies.remove(PROMPT_COOKIE_KEY);
|
Cookies.remove(PROMPT_COOKIE_KEY);
|
||||||
|
|
||||||
|
// Add file cleanup here
|
||||||
|
setUploadedFiles([]);
|
||||||
|
setImageDataList([]);
|
||||||
|
|
||||||
resetEnhancer();
|
resetEnhancer();
|
||||||
|
|
||||||
textareaRef.current?.blur();
|
textareaRef.current?.blur();
|
||||||
@ -321,6 +348,10 @@ export const ChatImpl = memo(
|
|||||||
apiKeys,
|
apiKeys,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
uploadedFiles={uploadedFiles}
|
||||||
|
setUploadedFiles={setUploadedFiles}
|
||||||
|
imageDataList={imageDataList}
|
||||||
|
setImageDataList={setImageDataList}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
35
app/components/chat/FilePreview.tsx
Normal file
35
app/components/chat/FilePreview.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
files: File[];
|
||||||
|
imageDataList: string[];
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemove }) => {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row overflow-x-auto -mt-2">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div key={file.name + file.size} className="mr-2 relative">
|
||||||
|
{imageDataList[index] && (
|
||||||
|
<div className="relative pt-4 pr-4">
|
||||||
|
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="i-ph:x w-3 h-3 text-gray-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilePreview;
|
63
app/components/chat/ModelSelector.tsx
Normal file
63
app/components/chat/ModelSelector.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import type { ProviderInfo } from '~/types/model';
|
||||||
|
import type { ModelInfo } from '~/utils/types';
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
model?: string;
|
||||||
|
setModel?: (model: string) => void;
|
||||||
|
provider?: ProviderInfo;
|
||||||
|
setProvider?: (provider: ProviderInfo) => void;
|
||||||
|
modelList: ModelInfo[];
|
||||||
|
providerList: ProviderInfo[];
|
||||||
|
apiKeys: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelector = ({
|
||||||
|
model,
|
||||||
|
setModel,
|
||||||
|
provider,
|
||||||
|
setProvider,
|
||||||
|
modelList,
|
||||||
|
providerList,
|
||||||
|
}: ModelSelectorProps) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
||||||
|
<select
|
||||||
|
value={provider?.name ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
|
||||||
|
|
||||||
|
if (newProvider && setProvider) {
|
||||||
|
setProvider(newProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
|
||||||
|
|
||||||
|
if (firstModel && setModel) {
|
||||||
|
setModel(firstModel.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{providerList.map((provider: ProviderInfo) => (
|
||||||
|
<option key={provider.name} value={provider.name}>
|
||||||
|
{provider.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
key={provider?.name}
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel?.(e.target.value)}
|
||||||
|
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%]"
|
||||||
|
>
|
||||||
|
{[...modelList]
|
||||||
|
.filter((e) => e.provider == provider?.name && e.name)
|
||||||
|
.map((modelOption) => (
|
||||||
|
<option key={modelOption.name} value={modelOption.name}>
|
||||||
|
{modelOption.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -4,11 +4,12 @@ interface SendButtonProps {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
onImagesSelected?: (images: File[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show ? (
|
{show ? (
|
||||||
@ -30,4 +31,4 @@ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
28
app/components/chat/SpeechRecognition.tsx
Normal file
28
app/components/chat/SpeechRecognition.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { IconButton } from '~/components/ui/IconButton';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const SpeechRecognitionButton = ({
|
||||||
|
isListening,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
isListening: boolean;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
title={isListening ? 'Stop listening' : 'Start speech recognition'}
|
||||||
|
disabled={disabled}
|
||||||
|
className={classNames('transition-all', {
|
||||||
|
'text-bolt-elements-item-contentAccent': isListening,
|
||||||
|
})}
|
||||||
|
onClick={isListening ? onStop : onStart}
|
||||||
|
>
|
||||||
|
{isListening ? <div className="i-ph:microphone-slash text-xl" /> : <div className="i-ph:microphone text-xl" />}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
@ -2,26 +2,52 @@
|
|||||||
* @ts-nocheck
|
* @ts-nocheck
|
||||||
* Preventing TS checks with files presented in the video for a better presentation.
|
* Preventing TS checks with files presented in the video for a better presentation.
|
||||||
*/
|
*/
|
||||||
import { modificationsRegex } from '~/utils/diff';
|
|
||||||
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
content: string;
|
content: string | Array<{ type: string; text?: string; image?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessage({ content }: UserMessageProps) {
|
export function UserMessage({ content }: UserMessageProps) {
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const textItem = content.find((item) => item.type === 'text');
|
||||||
|
const textContent = sanitizeUserMessage(textItem?.text || '');
|
||||||
|
const images = content.filter((item) => item.type === 'image' && item.image);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden pt-[4px]">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Markdown limitedMarkdown>{textContent}</Markdown>
|
||||||
|
</div>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="flex-shrink-0 w-[160px]">
|
||||||
|
{images.map((item, index) => (
|
||||||
|
<div key={index} className="relative">
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={`Uploaded image ${index + 1}`}
|
||||||
|
className="w-full h-[160px] rounded-lg object-cover border border-bolt-elements-borderColor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textContent = sanitizeUserMessage(content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden pt-[4px]">
|
<div className="overflow-hidden pt-[4px]">
|
||||||
<Markdown limitedMarkdown>{sanitizeUserMessage(content)}</Markdown>
|
<Markdown limitedMarkdown>{textContent}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeUserMessage(content: string) {
|
function sanitizeUserMessage(content: string) {
|
||||||
return content
|
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||||
.replace(modificationsRegex, '')
|
|
||||||
.replace(MODEL_REGEX, 'Using: $1')
|
|
||||||
.replace(PROVIDER_REGEX, ' ($1)\n\n')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
@ -24,17 +24,19 @@ export function Header() {
|
|||||||
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
<span className="i-bolt:logo-text?mask w-[46px] inline-block" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
|
||||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
<>
|
||||||
</span>
|
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||||
{chat.started && (
|
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||||
<ClientOnly>
|
</span>
|
||||||
{() => (
|
<ClientOnly>
|
||||||
<div className="mr-1">
|
{() => (
|
||||||
<HeaderActionButtons />
|
<div className="mr-1">
|
||||||
</div>
|
<HeaderActionButtons />
|
||||||
)}
|
</div>
|
||||||
</ClientOnly>
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
@ -19,7 +19,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
|||||||
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
active={showChat}
|
active={showChat}
|
||||||
disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's needed
|
disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (canHideChat) {
|
if (canHideChat) {
|
||||||
chatStore.setKey('showChat', !showChat);
|
chatStore.setKey('showChat', !showChat);
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
|
import { useParams } from '@remix-run/react';
|
||||||
|
import { classNames } from '~/utils/classNames';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { type ChatHistoryItem } from '~/lib/persistence';
|
import { type ChatHistoryItem } from '~/lib/persistence';
|
||||||
import WithTooltip from '~/components/ui/Tooltip';
|
import WithTooltip from '~/components/ui/Tooltip';
|
||||||
|
import { useEditChatDescription } from '~/lib/hooks';
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
item: ChatHistoryItem;
|
item: ChatHistoryItem;
|
||||||
@ -10,48 +13,115 @@ interface HistoryItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
|
export function HistoryItem({ item, onDelete, onDuplicate, exportChat }: HistoryItemProps) {
|
||||||
|
const { id: urlId } = useParams();
|
||||||
|
const isActiveChat = urlId === item.urlId;
|
||||||
|
|
||||||
|
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||||
|
useEditChatDescription({
|
||||||
|
initialDescription: item.description,
|
||||||
|
customChatId: item.id,
|
||||||
|
syncWithGlobalStore: isActiveChat,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderDescriptionForm = (
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 flex items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex-1 bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2"
|
||||||
|
autoFocus
|
||||||
|
value={currentDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="i-ph:check scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||||
|
onMouseDown={handleSubmit}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<div
|
||||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
className={classNames(
|
||||||
{item.description}
|
'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',
|
||||||
<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%">
|
{ '[&&]:text-bolt-elements-textPrimary bg-bolt-elements-background-depth-3': isActiveChat },
|
||||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
|
)}
|
||||||
<WithTooltip tooltip="Export chat">
|
>
|
||||||
<button
|
{editing ? (
|
||||||
type="button"
|
renderDescriptionForm
|
||||||
className="i-ph:download-simple scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
|
) : (
|
||||||
|
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||||
|
{currentDescription}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'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%',
|
||||||
|
{ 'from-bolt-elements-background-depth-3 w-10 ': isActiveChat },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center p-1 text-bolt-elements-textSecondary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<ChatActionButton
|
||||||
|
toolTipContent="Export chat"
|
||||||
|
icon="i-ph:download-simple"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
exportChat(item.id);
|
exportChat(item.id);
|
||||||
}}
|
}}
|
||||||
title="Export chat"
|
|
||||||
/>
|
/>
|
||||||
</WithTooltip>
|
{onDuplicate && (
|
||||||
{onDuplicate && (
|
<ChatActionButton
|
||||||
<WithTooltip tooltip="Duplicate chat">
|
toolTipContent="Duplicate chat"
|
||||||
<button
|
icon="i-ph:copy"
|
||||||
type="button"
|
|
||||||
className="i-ph:copy scale-110 mr-2 hover:text-bolt-elements-item-contentAccent"
|
|
||||||
onClick={() => onDuplicate?.(item.id)}
|
onClick={() => onDuplicate?.(item.id)}
|
||||||
title="Duplicate chat"
|
|
||||||
/>
|
/>
|
||||||
</WithTooltip>
|
)}
|
||||||
)}
|
<ChatActionButton
|
||||||
<Dialog.Trigger asChild>
|
toolTipContent="Rename chat"
|
||||||
<WithTooltip tooltip="Delete chat">
|
icon="i-ph:pencil-fill"
|
||||||
<button
|
onClick={(event) => {
|
||||||
type="button"
|
event.preventDefault();
|
||||||
className="i-ph:trash scale-110 hover:text-bolt-elements-button-danger-text"
|
toggleEditMode();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<ChatActionButton
|
||||||
|
toolTipContent="Delete chat"
|
||||||
|
icon="i-ph:trash"
|
||||||
|
className="[&&]:hover:text-bolt-elements-button-danger-text"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onDelete?.(event);
|
onDelete?.(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WithTooltip>
|
</Dialog.Trigger>
|
||||||
</Dialog.Trigger>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</a>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ChatActionButton = ({
|
||||||
|
toolTipContent,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
toolTipContent: string;
|
||||||
|
icon: string;
|
||||||
|
className?: string;
|
||||||
|
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
btnTitle?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<WithTooltip tooltip={toolTipContent}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`scale-110 mr-2 hover:text-bolt-elements-item-contentAccent ${icon} ${className ? className : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</WithTooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -33,7 +33,7 @@ const menuVariants = {
|
|||||||
|
|
||||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||||
|
|
||||||
export function Menu() {
|
export const Menu = () => {
|
||||||
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
||||||
@ -206,4 +206,4 @@ export function Menu() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
@ -4,11 +4,16 @@ import { IconButton } from '~/components/ui/IconButton';
|
|||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
import { PortDropdown } from './PortDropdown';
|
import { PortDropdown } from './PortDropdown';
|
||||||
|
|
||||||
|
type ResizeSide = 'left' | 'right' | null;
|
||||||
|
|
||||||
export const Preview = memo(() => {
|
export const Preview = memo(() => {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const hasSelectedPreview = useRef(false);
|
const hasSelectedPreview = useRef(false);
|
||||||
const previews = useStore(workbenchStore.previews);
|
const previews = useStore(workbenchStore.previews);
|
||||||
const activePreview = previews[activePreviewIndex];
|
const activePreview = previews[activePreviewIndex];
|
||||||
@ -16,6 +21,23 @@ export const Preview = memo(() => {
|
|||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||||
|
|
||||||
|
// Toggle between responsive mode and device mode
|
||||||
|
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||||
|
|
||||||
|
// Use percentage for width
|
||||||
|
const [widthPercent, setWidthPercent] = useState<number>(37.5); // 375px assuming 1000px window width initially
|
||||||
|
|
||||||
|
const resizingState = useRef({
|
||||||
|
isResizing: false,
|
||||||
|
side: null as ResizeSide,
|
||||||
|
startX: 0,
|
||||||
|
startWidthPercent: 37.5,
|
||||||
|
windowWidth: window.innerWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define the scaling factor
|
||||||
|
const SCALING_FACTOR = 2; // Adjust this value to increase/decrease sensitivity
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activePreview) {
|
if (!activePreview) {
|
||||||
setUrl('');
|
setUrl('');
|
||||||
@ -25,10 +47,9 @@ export const Preview = memo(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { baseUrl } = activePreview;
|
const { baseUrl } = activePreview;
|
||||||
|
|
||||||
setUrl(baseUrl);
|
setUrl(baseUrl);
|
||||||
setIframeUrl(baseUrl);
|
setIframeUrl(baseUrl);
|
||||||
}, [activePreview, iframeUrl]);
|
}, [activePreview]);
|
||||||
|
|
||||||
const validateUrl = useCallback(
|
const validateUrl = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@ -56,14 +77,13 @@ export const Preview = memo(() => {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// when previews change, display the lowest port if user hasn't selected a preview
|
// When previews change, display the lowest port if user hasn't selected a preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previews.length > 1 && !hasSelectedPreview.current) {
|
if (previews.length > 1 && !hasSelectedPreview.current) {
|
||||||
const minPortIndex = previews.reduce(findMinPortIndex, 0);
|
const minPortIndex = previews.reduce(findMinPortIndex, 0);
|
||||||
|
|
||||||
setActivePreviewIndex(minPortIndex);
|
setActivePreviewIndex(minPortIndex);
|
||||||
}
|
}
|
||||||
}, [previews]);
|
}, [previews, findMinPortIndex]);
|
||||||
|
|
||||||
const reloadPreview = () => {
|
const reloadPreview = () => {
|
||||||
if (iframeRef.current) {
|
if (iframeRef.current) {
|
||||||
@ -71,13 +91,134 @@ export const Preview = memo(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
if (!isFullscreen && containerRef.current) {
|
||||||
|
await containerRef.current.requestFullscreen();
|
||||||
|
} else if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDeviceMode = () => {
|
||||||
|
setIsDeviceModeOn((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startResizing = (e: React.MouseEvent, side: ResizeSide) => {
|
||||||
|
if (!isDeviceModeOn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent text selection
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
resizingState.current.isResizing = true;
|
||||||
|
resizingState.current.side = side;
|
||||||
|
resizingState.current.startX = e.clientX;
|
||||||
|
resizingState.current.startWidthPercent = widthPercent;
|
||||||
|
resizingState.current.windowWidth = window.innerWidth;
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
|
||||||
|
e.preventDefault(); // Prevent any text selection on mousedown
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!resizingState.current.isResizing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = e.clientX - resizingState.current.startX;
|
||||||
|
const windowWidth = resizingState.current.windowWidth;
|
||||||
|
|
||||||
|
// Apply scaling factor to increase sensitivity
|
||||||
|
const dxPercent = (dx / windowWidth) * 100 * SCALING_FACTOR;
|
||||||
|
|
||||||
|
let newWidthPercent = resizingState.current.startWidthPercent;
|
||||||
|
|
||||||
|
if (resizingState.current.side === 'right') {
|
||||||
|
newWidthPercent = resizingState.current.startWidthPercent + dxPercent;
|
||||||
|
} else if (resizingState.current.side === 'left') {
|
||||||
|
newWidthPercent = resizingState.current.startWidthPercent - dxPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp the width between 10% and 90%
|
||||||
|
newWidthPercent = Math.max(10, Math.min(newWidthPercent, 90));
|
||||||
|
|
||||||
|
setWidthPercent(newWidthPercent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
resizingState.current.isResizing = false;
|
||||||
|
resizingState.current.side = null;
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
|
||||||
|
// Restore text selection
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle window resize to ensure widthPercent remains valid
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWindowResize = () => {
|
||||||
|
/*
|
||||||
|
* Optional: Adjust widthPercent if necessary
|
||||||
|
* For now, since widthPercent is relative, no action is needed
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleWindowResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleWindowResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A small helper component for the handle's "grip" icon
|
||||||
|
const GripIcon = () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'rgba(0,0,0,0.5)',
|
||||||
|
fontSize: '10px',
|
||||||
|
lineHeight: '5px',
|
||||||
|
userSelect: 'none',
|
||||||
|
marginLeft: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
••• •••
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col">
|
<div ref={containerRef} className="w-full h-full flex flex-col relative">
|
||||||
{isPortDropdownOpen && (
|
{isPortDropdownOpen && (
|
||||||
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
|
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
|
||||||
)}
|
)}
|
||||||
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
||||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
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
|
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
|
||||||
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
|
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
|
||||||
@ -101,6 +242,7 @@ export const Preview = memo(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previews.length > 1 && (
|
{previews.length > 1 && (
|
||||||
<PortDropdown
|
<PortDropdown
|
||||||
activePreviewIndex={activePreviewIndex}
|
activePreviewIndex={activePreviewIndex}
|
||||||
@ -111,13 +253,93 @@ export const Preview = memo(() => {
|
|||||||
previews={previews}
|
previews={previews}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Device mode toggle button */}
|
||||||
|
<IconButton
|
||||||
|
icon="i-ph:devices"
|
||||||
|
onClick={toggleDeviceMode}
|
||||||
|
title={isDeviceModeOn ? 'Switch to Responsive Mode' : 'Switch to Device Mode'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fullscreen toggle button */}
|
||||||
|
<IconButton
|
||||||
|
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
title={isFullscreen ? 'Exit Full Screen' : 'Full Screen'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 border-t border-bolt-elements-borderColor">
|
|
||||||
{activePreview ? (
|
<div className="flex-1 border-t border-bolt-elements-borderColor flex justify-center items-center overflow-auto">
|
||||||
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
|
<div
|
||||||
) : (
|
style={{
|
||||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
width: isDeviceModeOn ? `${widthPercent}%` : '100%',
|
||||||
)}
|
height: '100%', // Always full height
|
||||||
|
overflow: 'visible',
|
||||||
|
background: '#fff',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activePreview ? (
|
||||||
|
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} allowFullScreen />
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDeviceModeOn && (
|
||||||
|
<>
|
||||||
|
{/* Left handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => startResizing(e, 'left')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '15px',
|
||||||
|
marginLeft: '-15px',
|
||||||
|
height: '100%',
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
background: 'rgba(255,255,255,.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
|
||||||
|
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
|
||||||
|
title="Drag to resize width"
|
||||||
|
>
|
||||||
|
<GripIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => startResizing(e, 'right')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: '15px',
|
||||||
|
marginRight: '-15px',
|
||||||
|
height: '100%',
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
background: 'rgba(255,255,255,.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.5)')}
|
||||||
|
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.2)')}
|
||||||
|
title="Drag to resize width"
|
||||||
|
>
|
||||||
|
<GripIcon />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -128,7 +128,12 @@ export function getXAIModel(apiKey: OptionalApiKey, model: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
|
export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
|
||||||
const apiKey = getAPIKey(env, provider, apiKeys);
|
/*
|
||||||
|
* let apiKey; // Declare first
|
||||||
|
* let baseURL;
|
||||||
|
*/
|
||||||
|
|
||||||
|
const apiKey = getAPIKey(env, provider, apiKeys); // Then assign
|
||||||
const baseURL = getBaseURL(env, provider);
|
const baseURL = getBaseURL(env, provider);
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
|
@ -23,16 +23,37 @@ export type Messages = Message[];
|
|||||||
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
|
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
|
||||||
|
|
||||||
function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
|
function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
|
||||||
// Extract model
|
const textContent = Array.isArray(message.content)
|
||||||
const modelMatch = message.content.match(MODEL_REGEX);
|
? message.content.find((item) => item.type === 'text')?.text || ''
|
||||||
|
: message.content;
|
||||||
|
|
||||||
|
const modelMatch = textContent.match(MODEL_REGEX);
|
||||||
|
const providerMatch = textContent.match(PROVIDER_REGEX);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Extract model
|
||||||
|
* const modelMatch = message.content.match(MODEL_REGEX);
|
||||||
|
*/
|
||||||
const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
|
const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
|
||||||
|
|
||||||
// Extract provider
|
/*
|
||||||
const providerMatch = message.content.match(PROVIDER_REGEX);
|
* Extract provider
|
||||||
|
* const providerMatch = message.content.match(PROVIDER_REGEX);
|
||||||
|
*/
|
||||||
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name;
|
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER.name;
|
||||||
|
|
||||||
// Remove model and provider lines from content
|
const cleanedContent = Array.isArray(message.content)
|
||||||
const cleanedContent = message.content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '').trim();
|
? message.content.map((item) => {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
return {
|
||||||
|
type: 'text',
|
||||||
|
text: item.text?.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item; // Preserve image_url and other types as is
|
||||||
|
})
|
||||||
|
: textContent.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
||||||
|
|
||||||
return { model, provider, content: cleanedContent };
|
return { model, provider, content: cleanedContent };
|
||||||
}
|
}
|
||||||
|
@ -2,4 +2,5 @@ export * from './useMessageParser';
|
|||||||
export * from './usePromptEnhancer';
|
export * from './usePromptEnhancer';
|
||||||
export * from './useShortcuts';
|
export * from './useShortcuts';
|
||||||
export * from './useSnapScroll';
|
export * from './useSnapScroll';
|
||||||
|
export * from './useEditChatDescription';
|
||||||
export { default } from './useViewport';
|
export { default } from './useViewport';
|
||||||
|
163
app/lib/hooks/useEditChatDescription.ts
Normal file
163
app/lib/hooks/useEditChatDescription.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import {
|
||||||
|
chatId as chatIdStore,
|
||||||
|
description as descriptionStore,
|
||||||
|
db,
|
||||||
|
updateChatDescription,
|
||||||
|
getMessages,
|
||||||
|
} from '~/lib/persistence';
|
||||||
|
|
||||||
|
interface EditChatDescriptionOptions {
|
||||||
|
initialDescription?: string;
|
||||||
|
customChatId?: string;
|
||||||
|
syncWithGlobalStore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditChatDescriptionHook = {
|
||||||
|
editing: boolean;
|
||||||
|
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
handleBlur: () => Promise<void>;
|
||||||
|
handleSubmit: (event: React.FormEvent) => Promise<void>;
|
||||||
|
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
currentDescription: string;
|
||||||
|
toggleEditMode: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage the state and behavior for editing chat descriptions.
|
||||||
|
*
|
||||||
|
* Offers functions to:
|
||||||
|
* - Switch between edit and view modes.
|
||||||
|
* - Manage input changes, blur, and form submission events.
|
||||||
|
* - Save updates to IndexedDB and optionally to the global application state.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.initialDescription - The current chat description.
|
||||||
|
* @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
|
||||||
|
* @param {boolean} options.syncWithGlobalStore - Flag to indicate global description store synchronization.
|
||||||
|
* @returns {EditChatDescriptionHook} Methods and state for managing description edits.
|
||||||
|
*/
|
||||||
|
export function useEditChatDescription({
|
||||||
|
initialDescription = descriptionStore.get()!,
|
||||||
|
customChatId,
|
||||||
|
syncWithGlobalStore,
|
||||||
|
}: EditChatDescriptionOptions): EditChatDescriptionHook {
|
||||||
|
const chatIdFromStore = useStore(chatIdStore);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [currentDescription, setCurrentDescription] = useState(initialDescription);
|
||||||
|
|
||||||
|
const [chatId, setChatId] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChatId(customChatId || chatIdFromStore);
|
||||||
|
}, [customChatId, chatIdFromStore]);
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentDescription(initialDescription);
|
||||||
|
}, [initialDescription]);
|
||||||
|
|
||||||
|
const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setCurrentDescription(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLatestDescription = useCallback(async () => {
|
||||||
|
if (!db || !chatId) {
|
||||||
|
return initialDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chat = await getMessages(db, chatId);
|
||||||
|
return chat?.description || initialDescription;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch latest description:', error);
|
||||||
|
return initialDescription;
|
||||||
|
}
|
||||||
|
}, [db, chatId, initialDescription]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(async () => {
|
||||||
|
const latestDescription = await fetchLatestDescription();
|
||||||
|
setCurrentDescription(latestDescription);
|
||||||
|
toggleEditMode();
|
||||||
|
}, [fetchLatestDescription, toggleEditMode]);
|
||||||
|
|
||||||
|
const isValidDescription = useCallback((desc: string): boolean => {
|
||||||
|
const trimmedDesc = desc.trim();
|
||||||
|
|
||||||
|
if (trimmedDesc === initialDescription) {
|
||||||
|
toggleEditMode();
|
||||||
|
return false; // No change, skip validation
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
|
||||||
|
const characterValid = /^[a-zA-Z0-9\s]+$/.test(trimmedDesc);
|
||||||
|
|
||||||
|
if (!lengthValid) {
|
||||||
|
toast.error('Description must be between 1 and 100 characters.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!characterValid) {
|
||||||
|
toast.error('Description can only contain alphanumeric characters and spaces.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!isValidDescription(currentDescription)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!db) {
|
||||||
|
toast.error('Chat persistence is not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chatId) {
|
||||||
|
toast.error('Chat Id is not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateChatDescription(db, chatId, currentDescription);
|
||||||
|
|
||||||
|
if (syncWithGlobalStore) {
|
||||||
|
descriptionStore.set(currentDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Chat description updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update chat description: ' + (error as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEditMode();
|
||||||
|
},
|
||||||
|
[currentDescription, db, chatId, initialDescription, customChatId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
await handleBlur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleBlur],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
handleChange,
|
||||||
|
handleBlur,
|
||||||
|
handleSubmit,
|
||||||
|
handleKeyDown,
|
||||||
|
currentDescription,
|
||||||
|
toggleEditMode,
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,68 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { description } from './useChatHistory';
|
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||||
|
import WithTooltip from '~/components/ui/Tooltip';
|
||||||
|
import { useEditChatDescription } from '~/lib/hooks';
|
||||||
|
import { description as descriptionStore } from '~/lib/persistence';
|
||||||
|
|
||||||
export function ChatDescription() {
|
export function ChatDescription() {
|
||||||
return useStore(description);
|
const initialDescription = useStore(descriptionStore)!;
|
||||||
|
|
||||||
|
const { editing, handleChange, handleBlur, handleSubmit, handleKeyDown, currentDescription, toggleEditMode } =
|
||||||
|
useEditChatDescription({
|
||||||
|
initialDescription,
|
||||||
|
syncWithGlobalStore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initialDescription) {
|
||||||
|
// doing this to prevent showing edit button until chat description is set
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 mr-2 w-fit"
|
||||||
|
autoFocus
|
||||||
|
value={currentDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{ width: `${Math.max(currentDescription.length * 8, 100)}px` }}
|
||||||
|
/>
|
||||||
|
<TooltipProvider>
|
||||||
|
<WithTooltip tooltip="Save title">
|
||||||
|
<div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="i-ph:check-bold scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||||
|
onMouseDown={handleSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</WithTooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{currentDescription}
|
||||||
|
<TooltipProvider>
|
||||||
|
<WithTooltip tooltip="Rename chat">
|
||||||
|
<div className="flex justify-between items-center p-2 rounded-md bg-bolt-elements-item-backgroundAccent ml-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="i-ph:pencil-fill scale-110 hover:text-bolt-elements-item-contentAccent"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleEditMode();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</WithTooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -52,17 +52,23 @@ export async function setMessages(
|
|||||||
messages: Message[],
|
messages: Message[],
|
||||||
urlId?: string,
|
urlId?: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
|
timestamp?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction('chats', 'readwrite');
|
const transaction = db.transaction('chats', 'readwrite');
|
||||||
const store = transaction.objectStore('chats');
|
const store = transaction.objectStore('chats');
|
||||||
|
|
||||||
|
if (timestamp && isNaN(Date.parse(timestamp))) {
|
||||||
|
reject(new Error('Invalid timestamp'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const request = store.put({
|
const request = store.put({
|
||||||
id,
|
id,
|
||||||
messages,
|
messages,
|
||||||
urlId,
|
urlId,
|
||||||
description,
|
description,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: timestamp ?? new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
request.onsuccess = () => resolve();
|
request.onsuccess = () => resolve();
|
||||||
@ -212,3 +218,17 @@ export async function createChatFromMessages(
|
|||||||
|
|
||||||
return newUrlId; // Return the urlId instead of id for navigation
|
return newUrlId; // Return the urlId instead of id for navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateChatDescription(db: IDBDatabase, id: string, description: string): Promise<void> {
|
||||||
|
const chat = await getMessages(db, id);
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
throw new Error('Chat not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description.trim()) {
|
||||||
|
throw new Error('Description cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
await setMessages(db, id, chat.messages, chat.urlId, description, chat.timestamp);
|
||||||
|
}
|
||||||
|
@ -100,6 +100,10 @@ export class ActionRunner {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Action failed:', error);
|
console.error('Action failed:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.#currentExecutionPromise;
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #executeAction(actionId: string, isStreaming: boolean = false) {
|
async #executeAction(actionId: string, isStreaming: boolean = false) {
|
||||||
|
@ -212,9 +212,5 @@ function isBinaryFile(buffer: Uint8Array | undefined) {
|
|||||||
* array buffer.
|
* array buffer.
|
||||||
*/
|
*/
|
||||||
function convertToBuffer(view: Uint8Array): Buffer {
|
function convertToBuffer(view: Uint8Array): Buffer {
|
||||||
const buffer = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
||||||
|
|
||||||
Object.setPrototypeOf(buffer, Buffer.prototype);
|
|
||||||
|
|
||||||
return buffer as Buffer;
|
|
||||||
}
|
}
|
||||||
|
@ -29,8 +29,9 @@ function parseCookies(cookieHeader:string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function chatAction({ context, request }: ActionFunctionArgs) {
|
async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||||
const { messages } = await request.json<{
|
const { messages, model } = await request.json<{
|
||||||
messages: Messages;
|
messages: Messages;
|
||||||
|
model: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const cookieHeader = request.headers.get('Cookie');
|
const cookieHeader = request.headers.get('Cookie');
|
||||||
|
@ -44,8 +44,9 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
|||||||
content:
|
content:
|
||||||
`[Model: ${model}]\n\n[Provider: ${providerName}]\n\n` +
|
`[Model: ${model}]\n\n[Provider: ${providerName}]\n\n` +
|
||||||
stripIndents`
|
stripIndents`
|
||||||
You are a professional prompt engineer specializing in crafting precise, effective prompts.
|
You are a professional prompt engineer specializing in crafting precise, effective prompts.
|
||||||
Your task is to enhance prompts by making them more specific, actionable, and effective.
|
Your task is to enhance prompts by making them more specific, actionable, and effective.
|
||||||
|
|
||||||
I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
|
I want you to improve the user prompt that is wrapped in \`<original_prompt>\` tags.
|
||||||
|
|
||||||
For valid prompts:
|
For valid prompts:
|
||||||
@ -55,12 +56,14 @@ async function enhancerAction({ context, request }: ActionFunctionArgs) {
|
|||||||
- Maintain the core intent
|
- Maintain the core intent
|
||||||
- Ensure the prompt is self-contained
|
- Ensure the prompt is self-contained
|
||||||
- Use professional language
|
- Use professional language
|
||||||
|
|
||||||
For invalid or unclear prompts:
|
For invalid or unclear prompts:
|
||||||
- Respond with a clear, professional guidance message
|
- Respond with a clear, professional guidance message
|
||||||
- Keep responses concise and actionable
|
- Keep responses concise and actionable
|
||||||
- Maintain a helpful, constructive tone
|
- Maintain a helpful, constructive tone
|
||||||
- Focus on what the user should provide
|
- Focus on what the user should provide
|
||||||
- Use a standard template for consistency
|
- Use a standard template for consistency
|
||||||
|
|
||||||
IMPORTANT: Your response must ONLY contain the enhanced prompt text.
|
IMPORTANT: Your response must ONLY contain the enhanced prompt text.
|
||||||
Do not include any explanations, metadata, or wrapper tags.
|
Do not include any explanations, metadata, or wrapper tags.
|
||||||
|
|
||||||
|
2
app/types/global.d.ts
vendored
2
app/types/global.d.ts
vendored
@ -1,3 +1,5 @@
|
|||||||
interface Window {
|
interface Window {
|
||||||
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
|
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
|
||||||
|
webkitSpeechRecognition: typeof SpeechRecognition;
|
||||||
|
SpeechRecognition: typeof SpeechRecognition;
|
||||||
}
|
}
|
||||||
|
25548
package-lock.json
generated
25548
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -101,6 +101,7 @@
|
|||||||
"@cloudflare/workers-types": "^4.20241127.0",
|
"@cloudflare/workers-types": "^4.20241127.0",
|
||||||
"@remix-run/dev": "^2.15.0",
|
"@remix-run/dev": "^2.15.0",
|
||||||
"@types/diff": "^5.2.3",
|
"@types/diff": "^5.2.3",
|
||||||
|
"@types/dom-speech-recognition": "^0.0.4",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
@ -222,6 +222,9 @@ importers:
|
|||||||
'@types/diff':
|
'@types/diff':
|
||||||
specifier: ^5.2.3
|
specifier: ^5.2.3
|
||||||
version: 5.2.3
|
version: 5.2.3
|
||||||
|
'@types/dom-speech-recognition':
|
||||||
|
specifier: ^0.0.4
|
||||||
|
version: 0.0.4
|
||||||
'@types/file-saver':
|
'@types/file-saver':
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7
|
version: 2.0.7
|
||||||
@ -2039,6 +2042,9 @@ packages:
|
|||||||
'@types/diff@5.2.3':
|
'@types/diff@5.2.3':
|
||||||
resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
|
resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==}
|
||||||
|
|
||||||
|
'@types/dom-speech-recognition@0.0.4':
|
||||||
|
resolution: {integrity: sha512-zf2GwV/G6TdaLwpLDcGTIkHnXf8JEf/viMux+khqKQKDa8/8BAUtXXZS563GnvJ4Fg0PBLGAaFf2GekEVSZ6GQ==}
|
||||||
|
|
||||||
'@types/eslint@8.56.10':
|
'@types/eslint@8.56.10':
|
||||||
resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==}
|
resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==}
|
||||||
|
|
||||||
@ -7464,6 +7470,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/diff@5.2.3': {}
|
'@types/diff@5.2.3': {}
|
||||||
|
|
||||||
|
'@types/dom-speech-recognition@0.0.4': {}
|
||||||
|
|
||||||
'@types/eslint@8.56.10':
|
'@types/eslint@8.56.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.6
|
'@types/estree': 1.0.6
|
||||||
@ -7812,7 +7820,7 @@ snapshots:
|
|||||||
'@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0)
|
'@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0)
|
||||||
'@vanilla-extract/babel-plugin-debug-ids': 1.1.0
|
'@vanilla-extract/babel-plugin-debug-ids': 1.1.0
|
||||||
'@vanilla-extract/css': 1.16.1
|
'@vanilla-extract/css': 1.16.1
|
||||||
esbuild: 0.17.6
|
esbuild: 0.17.19
|
||||||
eval: 0.1.8
|
eval: 0.1.8
|
||||||
find-up: 5.0.0
|
find-up: 5.0.0
|
||||||
javascript-stringify: 2.1.0
|
javascript-stringify: 2.1.0
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
"types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01"],
|
"types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01", "@types/dom-speech-recognition"],
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
@ -19,8 +19,7 @@ export default defineConfig((config) => {
|
|||||||
future: {
|
future: {
|
||||||
v3_fetcherPersist: true,
|
v3_fetcherPersist: true,
|
||||||
v3_relativeSplatPath: true,
|
v3_relativeSplatPath: true,
|
||||||
v3_throwAbortReason: true,
|
v3_throwAbortReason: true
|
||||||
v3_lazyRouteDiscovery: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
UnoCSS(),
|
UnoCSS(),
|
||||||
|
Loading…
Reference in New Issue
Block a user