Merge branch 'main' into Folder-import-refinement

This commit is contained in:
Anirban Kar
2024-12-08 01:17:24 +05:30
committed by GitHub
45 changed files with 2204 additions and 26254 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -2,15 +2,24 @@
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
echo "Running typecheck..."
which pnpm
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! 🚀"
exit 1 echo "Typecheck exit code: $?"
exit 1
fi fi
echo "Running lint..."
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! 🤩"
echo "lint exit code: $?"
exit 1 exit 1
fi fi

View File

@@ -34,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

View File

@@ -28,6 +28,7 @@ interface ArtifactProps {
export const Artifact = memo(({ messageId }: ArtifactProps) => { export const Artifact = memo(({ messageId }: ArtifactProps) => {
const userToggledActions = useRef(false); const userToggledActions = useRef(false);
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const [allActionFinished, setAllActionFinished] = useState(false);
const artifacts = useStore(workbenchStore.artifacts); const artifacts = useStore(workbenchStore.artifacts);
const artifact = artifacts[messageId]; const artifact = artifacts[messageId];
@@ -47,6 +48,14 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
if (actions.length && !showActions && !userToggledActions.current) { if (actions.length && !showActions && !userToggledActions.current) {
setShowActions(true); setShowActions(true);
} }
if (actions.length !== 0 && artifact.type === 'bundled') {
const finished = !actions.find((action) => action.status !== 'complete');
if (allActionFinished !== finished) {
setAllActionFinished(finished);
}
}
}, [actions]); }, [actions]);
return ( return (
@@ -59,6 +68,18 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
workbenchStore.showWorkbench.set(!showWorkbench); workbenchStore.showWorkbench.set(!showWorkbench);
}} }}
> >
{artifact.type == 'bundled' && (
<>
<div className="p-4">
{allActionFinished ? (
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
) : (
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
)}
</div>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
</>
)}
<div className="px-5 p-3.5 w-full text-left"> <div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div> <div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div> <div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
@@ -66,7 +87,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
</button> </button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" /> <div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence> <AnimatePresence>
{actions.length && ( {actions.length && artifact.type !== 'bundled' && (
<motion.button <motion.button
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: 'auto' }} animate={{ width: 'auto' }}
@@ -83,7 +104,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
</AnimatePresence> </AnimatePresence>
</div> </div>
<AnimatePresence> <AnimatePresence>
{showActions && actions.length > 0 && ( {artifact.type !== 'bundled' && showActions && actions.length > 0 && (
<motion.div <motion.div
className="actions" className="actions"
initial={{ height: 0 }} initial={{ height: 0 }}
@@ -92,6 +113,7 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => {
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
> >
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" /> <div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background"> <div className="p-5 text-left bg-bolt-elements-actions-background">
<ActionList actions={actions} /> <ActionList actions={actions} />
</div> </div>

View File

@@ -21,45 +21,11 @@ import type { ProviderInfo } from '~/utils/types';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
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';
import GitCloneButton from './GitCloneButton';
// @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 +51,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 +66,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 +91,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 +118,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 +201,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 +369,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 +384,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 +427,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 +450,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 +458,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 +494,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 ? (
@@ -361,8 +514,21 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
</div> </div>
</div> </div>
</div> </div>
{!chatStarted && ImportButtons(importChat)} {!chatStarted && (
{!chatStarted && ExamplePrompts(sendMessage)} <div className="flex justify-center gap-2">
{ImportButtons(importChat)}
<GitCloneButton importChat={importChat} />
</div>
)}
{!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>

View File

@@ -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}
/> />
); );
}, },

View 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;

View File

@@ -0,0 +1,103 @@
import ignore from 'ignore';
import { useGit } from '~/lib/hooks/useGit';
import type { Message } from 'ai';
import WithTooltip from '~/components/ui/Tooltip';
const IGNORE_PATTERNS = [
'node_modules/**',
'.git/**',
'.github/**',
'.vscode/**',
'**/*.jpg',
'**/*.jpeg',
'**/*.png',
'dist/**',
'build/**',
'.next/**',
'coverage/**',
'.cache/**',
'.vscode/**',
'.idea/**',
'**/*.log',
'**/.DS_Store',
'**/npm-debug.log*',
'**/yarn-debug.log*',
'**/yarn-error.log*',
'**/*lock.json',
'**/*lock.yaml',
];
const ig = ignore().add(IGNORE_PATTERNS);
const generateId = () => Math.random().toString(36).substring(2, 15);
interface GitCloneButtonProps {
className?: string;
importChat?: (description: string, messages: Message[]) => Promise<void>;
}
export default function GitCloneButton({ importChat }: GitCloneButtonProps) {
const { ready, gitClone } = useGit();
const onClick = async (_e: any) => {
if (!ready) {
return;
}
const repoUrl = prompt('Enter the Git url');
if (repoUrl) {
const { workdir, data } = await gitClone(repoUrl);
if (importChat) {
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
console.log(filePaths);
const textDecoder = new TextDecoder('utf-8');
const message: Message = {
role: 'assistant',
content: `Cloning the repo ${repoUrl} into ${workdir}
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled" >
${filePaths
.map((filePath) => {
const { data: content, encoding } = data[filePath];
if (encoding === 'utf8') {
return `<boltAction type="file" filePath="${filePath}">
${content}
</boltAction>`;
} else if (content instanceof Uint8Array) {
return `<boltAction type="file" filePath="${filePath}">
${textDecoder.decode(content)}
</boltAction>`;
} else {
return '';
}
})
.join('\n')}
</boltArtifact>`,
id: generateId(),
createdAt: new Date(),
};
console.log(JSON.stringify(message));
importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, [message]);
// console.log(files);
}
}
};
return (
<WithTooltip tooltip="Clone A Git Repo">
<button
onClick={(e) => {
onClick(e);
}}
title="Clone A Git Repo"
className="px-4 py-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 transition-all flex items-center gap-2"
>
<span className="i-ph:git-branch" />
Clone A Git Repo
</button>
</WithTooltip>
);
}

View File

@@ -21,7 +21,6 @@ export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ classNam
); );
return; return;
} }
const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder'; const folderName = allFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
setIsLoading(true); setIsLoading(true);
const loadingToast = toast.loading(`Importing ${folderName}...`); const loadingToast = toast.loading(`Importing ${folderName}...`);

View 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>
);
};

View File

@@ -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>
); );
} };

View 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>
);
};

View File

@@ -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();
} }

View File

@@ -5,7 +5,7 @@ import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) { export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
return ( return (
<div className="flex flex-col items-center justify-center flex-1 p-4"> <div className="flex flex-col items-center justify-center w-auto">
<input <input
type="file" type="file"
id="chat-import" id="chat-import"

View File

@@ -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>
); );

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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>
); );
} };

View File

@@ -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>
); );

View File

@@ -51,7 +51,7 @@ export function getAPIKey(cloudflareEnv: Env, provider: string, userApiKeys?: Re
export function getBaseURL(cloudflareEnv: Env, provider: string) { export function getBaseURL(cloudflareEnv: Env, provider: string) {
switch (provider) { switch (provider) {
case 'Together': case 'Together':
return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL; return env.TOGETHER_API_BASE_URL || cloudflareEnv.TOGETHER_API_BASE_URL || 'https://api.together.xyz/v1';
case 'OpenAILike': case 'OpenAILike':
return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL; return env.OPENAI_LIKE_API_BASE_URL || cloudflareEnv.OPENAI_LIKE_API_BASE_URL;
case 'LMStudio': case 'LMStudio':

View File

@@ -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) {

View File

@@ -1,11 +1,8 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck TODO: Provider proper types
import { convertToCoreMessages, streamText as _streamText } from 'ai'; import { convertToCoreMessages, streamText as _streamText } from 'ai';
import { getModel } from '~/lib/.server/llm/model'; import { getModel } from '~/lib/.server/llm/model';
import { MAX_TOKENS } from './constants'; import { MAX_TOKENS } from './constants';
import { getSystemPrompt } from './prompts'; import { getSystemPrompt } from './prompts';
import { DEFAULT_MODEL, DEFAULT_PROVIDER, MODEL_LIST, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants'; import { DEFAULT_MODEL, DEFAULT_PROVIDER, getModelList, MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
interface ToolResult<Name extends string, Args, Result> { interface ToolResult<Name extends string, Args, Result> {
toolCallId: string; toolCallId: string;
@@ -26,24 +23,50 @@ 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 provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER; * const providerMatch = message.content.match(PROVIDER_REGEX);
*/
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 };
} }
export function streamText(messages: Messages, env: Env, options?: StreamingOptions, apiKeys?: Record<string, string>) { export async function streamText(
messages: Messages,
env: Env,
options?: StreamingOptions,
apiKeys?: Record<string, string>,
) {
let currentModel = DEFAULT_MODEL; let currentModel = DEFAULT_MODEL;
let currentProvider = DEFAULT_PROVIDER; let currentProvider = DEFAULT_PROVIDER.name;
const MODEL_LIST = await getModelList(apiKeys || {});
const processedMessages = messages.map((message) => { const processedMessages = messages.map((message) => {
if (message.role === 'user') { if (message.role === 'user') {
const { model, provider, content } = extractPropertiesFromMessage(message); const { model, provider, content } = extractPropertiesFromMessage(message);
@@ -65,10 +88,10 @@ export function streamText(messages: Messages, env: Env, options?: StreamingOpti
const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS; const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
return _streamText({ return _streamText({
model: getModel(currentProvider, currentModel, env, apiKeys), model: getModel(currentProvider, currentModel, env, apiKeys) as any,
system: getSystemPrompt(), system: getSystemPrompt(),
maxTokens: dynamicMaxTokens, maxTokens: dynamicMaxTokens,
messages: convertToCoreMessages(processedMessages), messages: convertToCoreMessages(processedMessages as any),
...options, ...options,
}); });
} }

View File

@@ -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';

View 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,
};
}

287
app/lib/hooks/useGit.ts Normal file
View File

@@ -0,0 +1,287 @@
import type { WebContainer } from '@webcontainer/api';
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react';
import { webcontainer as webcontainerPromise } from '~/lib/webcontainer';
import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git';
import http from 'isomorphic-git/http/web';
import Cookies from 'js-cookie';
import { toast } from 'react-toastify';
const lookupSavedPassword = (url: string) => {
const domain = url.split('/')[2];
const gitCreds = Cookies.get(`git:${domain}`);
if (!gitCreds) {
return null;
}
try {
const { username, password } = JSON.parse(gitCreds || '{}');
return { username, password };
} catch (error) {
console.log(`Failed to parse Git Cookie ${error}`);
return null;
}
};
const saveGitAuth = (url: string, auth: GitAuth) => {
const domain = url.split('/')[2];
Cookies.set(`git:${domain}`, JSON.stringify(auth));
};
export function useGit() {
const [ready, setReady] = useState(false);
const [webcontainer, setWebcontainer] = useState<WebContainer>();
const [fs, setFs] = useState<PromiseFsClient>();
const fileData = useRef<Record<string, { data: any; encoding?: string }>>({});
useEffect(() => {
webcontainerPromise.then((container) => {
fileData.current = {};
setWebcontainer(container);
setFs(getFs(container, fileData));
setReady(true);
});
}, []);
const gitClone = useCallback(
async (url: string) => {
if (!webcontainer || !fs || !ready) {
throw 'Webcontainer not initialized';
}
fileData.current = {};
await git.clone({
fs,
http,
dir: webcontainer.workdir,
url,
depth: 1,
singleBranch: true,
corsProxy: 'https://cors.isomorphic-git.org',
onAuth: (url) => {
// let domain=url.split("/")[2]
let auth = lookupSavedPassword(url);
if (auth) {
return auth;
}
if (confirm('This repo is password protected. Ready to enter a username & password?')) {
auth = {
username: prompt('Enter username'),
password: prompt('Enter password'),
};
return auth;
} else {
return { cancel: true };
}
},
onAuthFailure: (url, _auth) => {
toast.error(`Error Authenticating with ${url.split('/')[2]}`);
},
onAuthSuccess: (url, auth) => {
saveGitAuth(url, auth);
},
});
const data: Record<string, { data: any; encoding?: string }> = {};
for (const [key, value] of Object.entries(fileData.current)) {
data[key] = value;
}
return { workdir: webcontainer.workdir, data };
},
[webcontainer],
);
return { ready, gitClone };
}
const getFs = (
webcontainer: WebContainer,
record: MutableRefObject<Record<string, { data: any; encoding?: string }>>,
) => ({
promises: {
readFile: async (path: string, options: any) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('readFile', relativePath, encoding);
return await webcontainer.fs.readFile(relativePath, encoding);
},
writeFile: async (path: string, data: any, options: any) => {
const encoding = options.encoding;
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('writeFile', { relativePath, data, encoding });
if (record.current) {
record.current[relativePath] = { data, encoding };
}
return await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding });
},
mkdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('mkdir', relativePath, options);
return await webcontainer.fs.mkdir(relativePath, { ...options, recursive: true });
},
readdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('readdir', relativePath, options);
return await webcontainer.fs.readdir(relativePath, options);
},
rm: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('rm', relativePath, options);
return await webcontainer.fs.rm(relativePath, { ...(options || {}) });
},
rmdir: async (path: string, options: any) => {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
console.log('rmdir', relativePath, options);
return await webcontainer.fs.rm(relativePath, { recursive: true, ...options });
},
// Mock implementations for missing functions
unlink: async (path: string) => {
// unlink is just removing a single file
const relativePath = pathUtils.relative(webcontainer.workdir, path);
return await webcontainer.fs.rm(relativePath, { recursive: false });
},
stat: async (path: string) => {
try {
const relativePath = pathUtils.relative(webcontainer.workdir, path);
const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true });
const name = pathUtils.basename(relativePath);
const fileInfo = resp.find((x) => x.name == name);
if (!fileInfo) {
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
}
return {
isFile: () => fileInfo.isFile(),
isDirectory: () => fileInfo.isDirectory(),
isSymbolicLink: () => false,
size: 1,
mode: 0o666, // Default permissions
mtimeMs: Date.now(),
uid: 1000,
gid: 1000,
};
} catch (error: any) {
console.log(error?.message);
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.syscall = 'stat';
err.path = path;
throw err;
}
},
lstat: async (path: string) => {
/*
* For basic usage, lstat can return the same as stat
* since we're not handling symbolic links
*/
return await getFs(webcontainer, record).promises.stat(path);
},
readlink: async (path: string) => {
/*
* Since WebContainer doesn't support symlinks,
* we'll throw a "not a symbolic link" error
*/
throw new Error(`EINVAL: invalid argument, readlink '${path}'`);
},
symlink: async (target: string, path: string) => {
/*
* Since WebContainer doesn't support symlinks,
* we'll throw a "operation not supported" error
*/
throw new Error(`EPERM: operation not permitted, symlink '${target}' -> '${path}'`);
},
chmod: async (_path: string, _mode: number) => {
/*
* WebContainer doesn't support changing permissions,
* but we can pretend it succeeded for compatibility
*/
return await Promise.resolve();
},
},
});
const pathUtils = {
dirname: (path: string) => {
// Handle empty or just filename cases
if (!path || !path.includes('/')) {
return '.';
}
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get directory part
return path.split('/').slice(0, -1).join('/') || '/';
},
basename: (path: string, ext?: string) => {
// Remove trailing slashes
path = path.replace(/\/+$/, '');
// Get the last part of the path
const base = path.split('/').pop() || '';
// If extension is provided, remove it from the result
if (ext && base.endsWith(ext)) {
return base.slice(0, -ext.length);
}
return base;
},
relative: (from: string, to: string): string => {
// Handle empty inputs
if (!from || !to) {
return '.';
}
// Normalize paths by removing trailing slashes and splitting
const normalizePathParts = (p: string) => p.replace(/\/+$/, '').split('/').filter(Boolean);
const fromParts = normalizePathParts(from);
const toParts = normalizePathParts(to);
// Find common parts at the start of both paths
let commonLength = 0;
const minLength = Math.min(fromParts.length, toParts.length);
for (let i = 0; i < minLength; i++) {
if (fromParts[i] !== toParts[i]) {
break;
}
commonLength++;
}
// Calculate the number of "../" needed
const upCount = fromParts.length - commonLength;
// Get the remaining path parts we need to append
const remainingPath = toParts.slice(commonLength);
// Construct the relative path
const relativeParts = [...Array(upCount).fill('..'), ...remainingPath];
// Handle empty result case
return relativeParts.length === 0 ? '.' : relativeParts.join('/');
},
};

View File

@@ -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>
);
} }

View File

@@ -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);
}

View File

@@ -29,6 +29,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -37,6 +38,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -96,6 +98,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -104,6 +107,7 @@ exports[`StreamingMessageParser > valid artifacts with actions > should correctl
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -112,6 +116,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -120,6 +125,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -128,6 +134,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": "bundled",
} }
`; `;
@@ -136,6 +143,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": "bundled",
} }
`; `;
@@ -144,6 +152,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -152,6 +161,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -160,6 +170,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -168,6 +179,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -176,6 +188,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -184,6 +197,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -192,6 +206,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -200,6 +215,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -208,6 +224,7 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;
@@ -216,5 +233,6 @@ exports[`StreamingMessageParser > valid artifacts without actions > should corre
"id": "artifact_1", "id": "artifact_1",
"messageId": "message_1", "messageId": "message_1",
"title": "Some title", "title": "Some title",
"type": undefined,
} }
`; `;

View File

@@ -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) {

View File

@@ -59,7 +59,11 @@ describe('StreamingMessageParser', () => {
}, },
], ],
[ [
['Some text before <boltArti', 'fact', ' title="Some title" id="artifact_1">foo</boltArtifact> Some more text'], [
'Some text before <boltArti',
'fact',
' title="Some title" id="artifact_1" type="bundled" >foo</boltArtifact> Some more text',
],
{ {
output: 'Some text before Some more text', output: 'Some text before Some more text',
callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 }, callbacks: { onArtifactOpen: 1, onArtifactClose: 1, onActionOpen: 0, onActionClose: 0 },

View File

@@ -192,6 +192,7 @@ export class StreamingMessageParser {
const artifactTag = input.slice(i, openTagEnd + 1); const artifactTag = input.slice(i, openTagEnd + 1);
const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string; const artifactTitle = this.#extractAttribute(artifactTag, 'title') as string;
const type = this.#extractAttribute(artifactTag, 'type') as string;
const artifactId = this.#extractAttribute(artifactTag, 'id') as string; const artifactId = this.#extractAttribute(artifactTag, 'id') as string;
if (!artifactTitle) { if (!artifactTitle) {
@@ -207,6 +208,7 @@ export class StreamingMessageParser {
const currentArtifact = { const currentArtifact = {
id: artifactId, id: artifactId,
title: artifactTitle, title: artifactTitle,
type,
} satisfies BoltArtifactData; } satisfies BoltArtifactData;
state.currentArtifact = currentArtifact; state.currentArtifact = currentArtifact;

View File

@@ -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;
} }

View File

@@ -19,6 +19,7 @@ import { description } from '~/lib/persistence';
export interface ArtifactState { export interface ArtifactState {
id: string; id: string;
title: string; title: string;
type?: string;
closed: boolean; closed: boolean;
runner: ActionRunner; runner: ActionRunner;
} }
@@ -230,7 +231,7 @@ export class WorkbenchStore {
// TODO: what do we wanna do and how do we wanna recover from this? // TODO: what do we wanna do and how do we wanna recover from this?
} }
addArtifact({ messageId, title, id }: ArtifactCallbackData) { addArtifact({ messageId, title, id, type }: ArtifactCallbackData) {
const artifact = this.#getArtifact(messageId); const artifact = this.#getArtifact(messageId);
if (artifact) { if (artifact) {
@@ -245,6 +246,7 @@ export class WorkbenchStore {
id, id,
title, title,
closed: false, closed: false,
type,
runner: new ActionRunner(webcontainer, () => this.boltTerminal), runner: new ActionRunner(webcontainer, () => this.boltTerminal),
}); });
} }

View File

@@ -1,6 +1,3 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck TODO: Provider proper types
import { type ActionFunctionArgs } from '@remix-run/cloudflare'; import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants'; import { MAX_RESPONSE_SEGMENTS, MAX_TOKENS } from '~/lib/.server/llm/constants';
import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts'; import { CONTINUE_PROMPT } from '~/lib/.server/llm/prompts';
@@ -11,8 +8,8 @@ export async function action(args: ActionFunctionArgs) {
return chatAction(args); return chatAction(args);
} }
function parseCookies(cookieHeader) { function parseCookies(cookieHeader: string) {
const cookies = {}; const cookies: any = {};
// Split the cookie string by semicolons and spaces // Split the cookie string by semicolons and spaces
const items = cookieHeader.split(';').map((cookie) => cookie.trim()); const items = cookieHeader.split(';').map((cookie) => cookie.trim());
@@ -34,19 +31,19 @@ function parseCookies(cookieHeader) {
async function chatAction({ context, request }: ActionFunctionArgs) { async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages } = await request.json<{ const { messages } = await request.json<{
messages: Messages; messages: Messages;
model: string;
}>(); }>();
const cookieHeader = request.headers.get('Cookie'); const cookieHeader = request.headers.get('Cookie');
// Parse the cookie's value (returns an object or null if no cookie exists) // Parse the cookie's value (returns an object or null if no cookie exists)
const apiKeys = JSON.parse(parseCookies(cookieHeader).apiKeys || '{}'); const apiKeys = JSON.parse(parseCookies(cookieHeader || '').apiKeys || '{}');
const stream = new SwitchableStream(); const stream = new SwitchableStream();
try { try {
const options: StreamingOptions = { const options: StreamingOptions = {
toolChoice: 'none', toolChoice: 'none',
apiKeys,
onFinish: async ({ text: content, finishReason }) => { onFinish: async ({ text: content, finishReason }) => {
if (finishReason !== 'length') { if (finishReason !== 'length') {
return stream.close(); return stream.close();
@@ -63,7 +60,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
messages.push({ role: 'assistant', content }); messages.push({ role: 'assistant', content });
messages.push({ role: 'user', content: CONTINUE_PROMPT }); messages.push({ role: 'user', content: CONTINUE_PROMPT });
const result = await streamText(messages, context.cloudflare.env, options); const result = await streamText(messages, context.cloudflare.env, options, apiKeys);
return stream.switchSource(result.toAIStream()); return stream.switchSource(result.toAIStream());
}, },
@@ -79,7 +76,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
contentType: 'text/plain; charset=utf-8', contentType: 'text/plain; charset=utf-8',
}, },
}); });
} catch (error) { } catch (error: any) {
console.log(error); console.log(error);
if (error.message?.includes('API key')) { if (error.message?.includes('API key')) {

View File

@@ -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.

View File

@@ -1,4 +1,5 @@
export interface BoltArtifactData { export interface BoltArtifactData {
id: string; id: string;
title: string; title: string;
type?: string | undefined;
} }

View File

@@ -1,3 +1,5 @@
interface Window { interface Window {
showDirectoryPicker(): Promise<FileSystemDirectoryHandle>; showDirectoryPicker(): Promise<FileSystemDirectoryHandle>;
webkitSpeechRecognition: typeof SpeechRecognition;
SpeechRecognition: typeof SpeechRecognition;
} }

View File

@@ -3,7 +3,7 @@ import type { ModelInfo } from '~/utils/types';
export type ProviderInfo = { export type ProviderInfo = {
staticModels: ModelInfo[]; staticModels: ModelInfo[];
name: string; name: string;
getDynamicModels?: () => Promise<ModelInfo[]>; getDynamicModels?: (apiKeys?: Record<string, string>) => Promise<ModelInfo[]>;
getApiKeyLink?: string; getApiKeyLink?: string;
labelForGetApiKey?: string; labelForGetApiKey?: string;
icon?: string; icon?: string;

View File

@@ -1,3 +1,4 @@
import Cookies from 'js-cookie';
import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types'; import type { ModelInfo, OllamaApiResponse, OllamaModel } from './types';
import type { ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
@@ -262,6 +263,7 @@ const PROVIDER_LIST: ProviderInfo[] = [
}, },
{ {
name: 'Together', name: 'Together',
getDynamicModels: getTogetherModels,
staticModels: [ staticModels: [
{ {
name: 'Qwen/Qwen2.5-Coder-32B-Instruct', name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
@@ -293,6 +295,61 @@ const staticModels: ModelInfo[] = PROVIDER_LIST.map((p) => p.staticModels).flat(
export let MODEL_LIST: ModelInfo[] = [...staticModels]; export let MODEL_LIST: ModelInfo[] = [...staticModels];
export async function getModelList(apiKeys: Record<string, string>) {
MODEL_LIST = [
...(
await Promise.all(
PROVIDER_LIST.filter(
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
).map((p) => p.getDynamicModels(apiKeys)),
)
).flat(),
...staticModels,
];
return MODEL_LIST;
}
async function getTogetherModels(apiKeys?: Record<string, string>): Promise<ModelInfo[]> {
try {
const baseUrl = import.meta.env.TOGETHER_API_BASE_URL || '';
const provider = 'Together';
if (!baseUrl) {
return [];
}
let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
if (apiKeys && apiKeys[provider]) {
apiKey = apiKeys[provider];
}
if (!apiKey) {
return [];
}
const response = await fetch(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const res = (await response.json()) as any;
const data: any[] = (res || []).filter((model: any) => model.type == 'chat');
return data.map((m: any) => ({
name: m.id,
label: `${m.display_name} - in:$${m.pricing.input.toFixed(
2,
)} out:$${m.pricing.output.toFixed(2)} - context ${Math.floor(m.context_length / 1000)}k`,
provider,
maxTokenAllowed: 8000,
}));
} catch (e) {
console.error('Error getting OpenAILike models:', e);
return [];
}
}
const getOllamaBaseUrl = () => { const getOllamaBaseUrl = () => {
const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434'; const defaultBaseUrl = import.meta.env.OLLAMA_API_BASE_URL || 'http://localhost:11434';
@@ -340,7 +397,14 @@ async function getOpenAILikeModels(): Promise<ModelInfo[]> {
return []; return [];
} }
const apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? ''; let apiKey = import.meta.env.OPENAI_LIKE_API_KEY ?? '';
const apikeys = JSON.parse(Cookies.get('apiKeys') || '{}');
if (apikeys && apikeys.OpenAILike) {
apiKey = apikeys.OpenAILike;
}
const response = await fetch(`${baseUrl}/models`, { const response = await fetch(`${baseUrl}/models`, {
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
@@ -414,16 +478,32 @@ async function getLMStudioModels(): Promise<ModelInfo[]> {
} }
async function initializeModelList(): Promise<ModelInfo[]> { async function initializeModelList(): Promise<ModelInfo[]> {
let apiKeys: Record<string, string> = {};
try {
const storedApiKeys = Cookies.get('apiKeys');
if (storedApiKeys) {
const parsedKeys = JSON.parse(storedApiKeys);
if (typeof parsedKeys === 'object' && parsedKeys !== null) {
apiKeys = parsedKeys;
}
}
} catch (error: any) {
console.warn(`Failed to fetch apikeys from cookies:${error?.message}`);
}
MODEL_LIST = [ MODEL_LIST = [
...( ...(
await Promise.all( await Promise.all(
PROVIDER_LIST.filter( PROVIDER_LIST.filter(
(p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels, (p): p is ProviderInfo & { getDynamicModels: () => Promise<ModelInfo[]> } => !!p.getDynamicModels,
).map((p) => p.getDynamicModels()), ).map((p) => p.getDynamicModels(apiKeys)),
) )
).flat(), ).flat(),
...staticModels, ...staticModels,
]; ];
return MODEL_LIST; return MODEL_LIST;
} }

25548
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@
"@openrouter/ai-sdk-provider": "^0.0.5", "@openrouter/ai-sdk-provider": "^0.0.5",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@remix-run/cloudflare": "^2.15.0", "@remix-run/cloudflare": "^2.15.0",
"@remix-run/cloudflare-pages": "^2.15.0", "@remix-run/cloudflare-pages": "^2.15.0",
@@ -75,13 +76,13 @@
"framer-motion": "^11.12.0", "framer-motion": "^11.12.0",
"ignore": "^6.0.2", "ignore": "^6.0.2",
"isbot": "^4.4.0", "isbot": "^4.4.0",
"isomorphic-git": "^1.27.2",
"istextorbinary": "^9.5.0", "istextorbinary": "^9.5.0",
"jose": "^5.9.6", "jose": "^5.9.6",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"ollama-ai-provider": "^0.15.2", "ollama-ai-provider": "^0.15.2",
"pnpm": "^9.14.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.1",
@@ -101,6 +102,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",
@@ -109,6 +111,7 @@
"husky": "9.1.7", "husky": "9.1.7",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"pnpm": "^9.14.4",
"prettier": "^3.4.1", "prettier": "^3.4.1",
"sass-embedded": "^1.81.0", "sass-embedded": "^1.81.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",

1074
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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(),
@@ -28,7 +27,7 @@ export default defineConfig((config) => {
chrome129IssuePlugin(), chrome129IssuePlugin(),
config.mode === 'production' && optimizeCssModules({ apply: 'build' }), config.mode === 'production' && optimizeCssModules({ apply: 'build' }),
], ],
envPrefix:["VITE_","OPENAI_LIKE_API_","OLLAMA_API_BASE_URL","LMSTUDIO_API_BASE_URL"], envPrefix: ["VITE_", "OPENAI_LIKE_API_", "OLLAMA_API_BASE_URL", "LMSTUDIO_API_BASE_URL","TOGETHER_API_BASE_URL"],
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {